Files
RenewCMS/test/article_seo_index_persistence_test.go
T

201 lines
8.0 KiB
Go

package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"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
}
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 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, e.articles)
_, 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)
}