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", "
Body.
" 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, "Changed.
") 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) }