248 lines
9.5 KiB
Go
248 lines
9.5 KiB
Go
package test
|
|
|
|
import (
|
|
domainArticle "RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/author"
|
|
"RenewCMS/internal/domain/localization"
|
|
"RenewCMS/internal/domain/site"
|
|
"RenewCMS/internal/domain/transaction"
|
|
entity "RenewCMS/internal/infrastructure/persistence/models"
|
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type seoIndexEnv struct {
|
|
t *testing.T
|
|
db *gorm.DB
|
|
articles *persistence.ArticleRepository
|
|
sites *persistence.SiteRepository
|
|
rows *persistence.LocalizationRepository
|
|
refresh *useCases.RefreshArticleSEOUseCase
|
|
siteID uint32
|
|
}
|
|
|
|
var errSEOClearFailed = errors.New("the SEO clear failed")
|
|
|
|
type failingSEOClear struct {
|
|
domainArticle.SEOIndex
|
|
}
|
|
|
|
func (failingSEOClear) MarkSiteSEOStale(uint32) error {
|
|
return errSEOClearFailed
|
|
}
|
|
|
|
type failingSEOClearTransactor struct {
|
|
inner transaction.Transactor
|
|
}
|
|
|
|
func (f failingSEOClearTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
|
|
return f.inner.Run(ctx, func(repositories transaction.Repositories) error {
|
|
repositories.ArticleSEO = failingSEOClear{repositories.ArticleSEO}
|
|
return work(repositories)
|
|
})
|
|
}
|
|
|
|
func newSEOIndexEnv(t *testing.T, open func(*testing.T) *gorm.DB) *seoIndexEnv {
|
|
t.Helper()
|
|
db := open(t)
|
|
e := &seoIndexEnv{t: t, db: db, articles: persistence.NewArticleRepository(db), sites: persistence.NewSiteRepository(db), rows: persistence.NewLocalizationRepository(db)}
|
|
e.refresh = useCases.NewRefreshArticleSEOUseCase(e.articles, e.sites, e.rows)
|
|
e.siteID = storageDefaultSite(t, db).ID
|
|
e.must(db.Where("site_id = ?", e.siteID).Delete(&entity.SiteLanguage{}).Error)
|
|
e.must(e.rows.Replace(e.siteID, []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "fr", Enabled: true}}))
|
|
return e
|
|
}
|
|
|
|
func (e *seoIndexEnv) must(err error) {
|
|
e.t.Helper()
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (e *seoIndexEnv) create(lang string, storyID uint32) domainArticle.Article {
|
|
e.t.Helper()
|
|
created, err := e.articles.Create(domainArticle.Article{Title: "Harbour " + lang, SiteID: e.siteID, Lang: lang, Slug: uniqueTestSlug(), StoryID: storyID, SEODismissed: []string{}})
|
|
e.must(err)
|
|
return created
|
|
}
|
|
|
|
func (e *seoIndexEnv) status(id uint32) string {
|
|
e.t.Helper()
|
|
var statuses []string
|
|
e.must(e.db.Model(&entity.Article{}).Where("id = ?", id).Pluck("seo_status", &statuses).Error)
|
|
return statuses[0]
|
|
}
|
|
|
|
func (e *seoIndexEnv) expectRecomputed(t *testing.T, name string, id uint32) {
|
|
t.Helper()
|
|
if e.status(id) != "" {
|
|
t.Fatalf("%s left the stored status in place", name)
|
|
}
|
|
e.must(e.refresh.RefreshStale())
|
|
if e.status(id) == "" {
|
|
t.Fatalf("%s: the refresh did not store a status", name)
|
|
}
|
|
}
|
|
|
|
func (e *seoIndexEnv) suggestions(id uint32) int {
|
|
e.t.Helper()
|
|
page, err := useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, e.sites, e.rows).ListArticlesPage(useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 100})
|
|
e.must(err)
|
|
return page.SEO[id].Suggestions
|
|
}
|
|
|
|
func runArticleSEOIndex(t *testing.T, open func(*testing.T) *gorm.DB) {
|
|
t.Run("Every write to an SEO input clears the stored status until it is recomputed", func(t *testing.T) {
|
|
e := newSEOIndexEnv(t, open)
|
|
created := e.create("en", 0)
|
|
unassigned := e.create("", 0)
|
|
image := entity.Image{Path: "/static/uploadedImages/seo-index.png", Filename: "seo-index.png"}
|
|
e.must(e.db.Create(&image).Error)
|
|
other, err := e.sites.Create(site.Site{Name: "Atelier", Key: "atelier", BaseURL: "https://atelier.example"})
|
|
e.must(err)
|
|
credited, err := persistence.NewAuthorRepository(e.db).Create(author.Author{Name: "Noor", Slug: "noor"})
|
|
e.must(err)
|
|
e.expectRecomputed(t, "create", created.ID)
|
|
title, body := "Harbour light", "<p>Body.</p>"
|
|
cover := domainArticle.Nullable[uint32]{Set: true, Value: &image.ID}
|
|
|
|
writes := []struct {
|
|
name string
|
|
id uint32
|
|
write func() error
|
|
}{
|
|
{"a title edit", created.ID, func() error { _, err := e.articles.Update(created.ID, domainArticle.Update{Title: &title}); return err }},
|
|
{"a body edit", created.ID, func() error { _, err := e.articles.UpdateBody(created.ID, body); return err }},
|
|
{"a new cover", created.ID, func() error {
|
|
_, err := e.articles.Update(created.ID, domainArticle.Update{CoverImageID: cover})
|
|
return err
|
|
}},
|
|
{"a deleted cover", created.ID, func() error { _, err := e.articles.ClearImage(image.ID); return err }},
|
|
{"a restored cover", created.ID, func() error {
|
|
return e.articles.RestoreImage(domainArticle.ImageReferences{Cover: []uint32{created.ID}}, image.ID)
|
|
}},
|
|
{"a merged cover", created.ID, func() error { _, err := e.articles.RepointImage([]uint32{image.ID}, image.ID); return err }},
|
|
{"a credited author", created.ID, func() error {
|
|
ids := []uint32{credited.ID}
|
|
_, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &ids})
|
|
return err
|
|
}},
|
|
{"a deleted author", created.ID, func() error { return persistence.NewAuthorRepository(e.db).DeleteUncrediting(credited.ID) }},
|
|
{"a site description or default language change", created.ID, func() error { return e.articles.MarkSiteSEOStale(e.siteID) }},
|
|
{"a language assignment", unassigned.ID, func() error { _, err := e.articles.AssignUnassignedLanguage(e.siteID, "fr"); return err }},
|
|
{"a move to another site", created.ID, func() error { return e.articles.MoveToSite([]uint32{created.ID}, other.ID) }},
|
|
}
|
|
for _, w := range writes {
|
|
e.must(e.refresh.RefreshStale())
|
|
e.must(w.write())
|
|
e.expectRecomputed(t, w.name, w.id)
|
|
}
|
|
})
|
|
|
|
t.Run("A site description change whose SEO clear fails saves nothing", func(t *testing.T) {
|
|
e := newSEOIndexEnv(t, open)
|
|
created := e.create("en", 0)
|
|
e.must(e.refresh.RefreshStale())
|
|
before, err := e.sites.Get(e.siteID)
|
|
e.must(err)
|
|
description := "A description that is never saved."
|
|
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, failingSEOClearTransactor{persistence.NewTransactor(e.db)})
|
|
|
|
_, err = update.Update(context.Background(), e.siteID, site.Patch{DefaultDescription: &description})
|
|
|
|
if !errors.Is(err, errSEOClearFailed) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
after, err := e.sites.Get(e.siteID)
|
|
e.must(err)
|
|
if after.DefaultDescription != before.DefaultDescription {
|
|
t.Fatalf("the description %q was saved", after.DefaultDescription)
|
|
}
|
|
if e.status(created.ID) == "" {
|
|
t.Fatal("the stored status was cleared")
|
|
}
|
|
})
|
|
|
|
t.Run("A status computed before a concurrent write is not stored", func(t *testing.T) {
|
|
e := newSEOIndexEnv(t, open)
|
|
created := e.create("en", 0)
|
|
stale, err := e.articles.StaleSEO(0, 10)
|
|
e.must(err)
|
|
_, err = e.articles.UpdateBody(created.ID, "<p>Changed.</p>")
|
|
e.must(err)
|
|
|
|
e.must(e.articles.StoreSEO([]domainArticle.SEORecord{{ArticleID: created.ID, Version: stale[0].SEOVersion, Summary: domainArticle.SEOSummary{Status: domainArticle.SEOStatusReady}}}))
|
|
|
|
if e.status(created.ID) != "" {
|
|
t.Fatal("a status read before the write was stored over it")
|
|
}
|
|
})
|
|
|
|
t.Run("The site description counts for the default language only and follows a default change", func(t *testing.T) {
|
|
e := newSEOIndexEnv(t, open)
|
|
english := e.create("en", 0)
|
|
french := e.create("fr", english.StoryID)
|
|
description := strings.Repeat("Renovation notes. ", 6)
|
|
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, persistence.NewTransactor(e.db))
|
|
_, err := update.Update(context.Background(), e.siteID, site.Patch{DefaultDescription: &description})
|
|
e.must(err)
|
|
|
|
if e.suggestions(french.ID) != e.suggestions(english.ID)+1 {
|
|
t.Fatalf("site text: en %d, fr %d suggestions", e.suggestions(english.ID), e.suggestions(french.ID))
|
|
}
|
|
current, err := e.rows.Rows(e.siteID)
|
|
e.must(err)
|
|
languages := useCases.NewUpdateSiteLanguagesUseCase(persistence.NewTransactor(e.db), discardRecorder)
|
|
_, err = languages.Update(context.Background(), useCases.UpdateSiteLanguagesCommand{Key: storageDefaultSite(t, e.db).Key, Revision: localization.Revision(current), Default: "fr", Languages: []useCases.SiteLanguageEntry{{Lang: "en", Enabled: true}, {Lang: "fr", Enabled: true}}})
|
|
e.must(err)
|
|
|
|
if e.suggestions(english.ID) != e.suggestions(french.ID)+1 {
|
|
t.Fatalf("after the default change: en %d, fr %d suggestions", e.suggestions(english.ID), e.suggestions(french.ID))
|
|
}
|
|
})
|
|
|
|
t.Run("Deleting an article removes every version and the article row", func(t *testing.T) {
|
|
e := newSEOIndexEnv(t, open)
|
|
english := e.create("en", 0)
|
|
french := e.create("fr", english.StoryID)
|
|
|
|
e.must(e.articles.DeleteStory(english.StoryID))
|
|
|
|
var live, stories int64
|
|
e.must(e.db.Model(&entity.Article{}).Where("story_id = ?", english.StoryID).Count(&live).Error)
|
|
e.must(e.db.Model(&entity.Story{}).Where("id = ?", english.StoryID).Count(&stories).Error)
|
|
if live != 0 || stories != 0 {
|
|
t.Fatalf("live versions %d, story rows %d", live, stories)
|
|
}
|
|
for _, version := range []domainArticle.Article{english, french} {
|
|
taken, err := e.articles.SlugTaken(e.siteID, version.Lang, version.Slug, 0)
|
|
e.must(err)
|
|
if taken {
|
|
t.Errorf("the slug of %s is still taken", version.Lang)
|
|
}
|
|
}
|
|
if err := e.articles.DeleteStory(english.StoryID); err == nil {
|
|
t.Error("deleting a deleted article must answer not found")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestArticleSEOIndexSQLite(t *testing.T) {
|
|
runArticleSEOIndex(t, openContractSQLite)
|
|
}
|
|
|
|
func TestArticleSEOIndexMySQL(t *testing.T) {
|
|
runArticleSEOIndex(t, openContractMySQL)
|
|
}
|
|
|
|
func TestArticleSEOIndexPostgres(t *testing.T) {
|
|
runArticleSEOIndex(t, openContractPostgres)
|
|
}
|