feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+6094 -350

No files matched your search

+2 -2
View File
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
t.Fatal(err)
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil {
t.Fatal(err)
+3 -3
View File
@@ -108,7 +108,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
@@ -129,11 +129,11 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), env.articles),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites(), plainAuthors()),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors()),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
+1 -1
View File
@@ -47,7 +47,7 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -26,7 +26,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -27,7 +27,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+135
View File
@@ -23,6 +23,8 @@ type mockArticleRepo struct {
createCalled bool
credits map[uint32][]uint32
authors *mockAuthorRepo
reportSlugRace bool
createAlwaysRaces bool
}
func newMockArticleRepo() *mockArticleRepo {
@@ -193,11 +195,107 @@ func (m *mockArticleRepo) GetAll() []article.Article {
return result
}
func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool {
if slug == "" {
return false
}
for id, other := range m.articles {
if id != exceptID && other.SiteID == siteID && other.Slug == slug {
return true
}
}
return false
}
func (m *mockArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
if err := m.fail.trigger("SlugTaken"); err != nil {
return false, err
}
return m.slugUsedOn(siteID, slug, exceptID), nil
}
func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) {
if err := m.fail.trigger("FindByImage"); err != nil {
return nil, err
}
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
if a := m.articles[id]; a.UsesImage(imageID) {
result = append(result, a)
}
}
return result, nil
}
func (m *mockArticleRepo) ClearImage(imageID uint32) (article.ImageReferences, error) {
if err := m.fail.trigger("ClearImage"); err != nil {
return article.ImageReferences{}, err
}
references := article.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
for _, id := range m.sortedIds() {
a := m.articles[id]
if a.CoverImageID != nil && *a.CoverImageID == imageID {
a.CoverImageID = nil
references.Cover = append(references.Cover, id)
}
if a.SocialImageID != nil && *a.SocialImageID == imageID {
a.SocialImageID = nil
references.Social = append(references.Social, id)
}
m.articles[id] = a
}
return references, nil
}
func (m *mockArticleRepo) RestoreImage(references article.ImageReferences, imageID uint32) error {
for _, id := range references.Cover {
a := m.articles[id]
a.CoverImageID = idPointer(imageID)
m.articles[id] = a
}
for _, id := range references.Social {
a := m.articles[id]
a.SocialImageID = idPointer(imageID)
m.articles[id] = a
}
return nil
}
func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
if err := m.fail.trigger("RepointImage"); err != nil {
return nil, err
}
changed := []uint32{}
for _, id := range m.sortedIds() {
a := m.articles[id]
touched := false
if a.CoverImageID != nil && containsID(fromImageIDs, *a.CoverImageID) {
a.CoverImageID = idPointer(toImageID)
touched = true
}
if a.SocialImageID != nil && containsID(fromImageIDs, *a.SocialImageID) {
a.SocialImageID = idPointer(toImageID)
touched = true
}
if touched {
m.articles[id] = a
changed = append(changed, id)
}
}
return changed, nil
}
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
m.createCalled = true
if err := m.fail.trigger("Create"); err != nil {
return article.Article{}, err
}
if m.createAlwaysRaces {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, 0) {
return article.Article{}, article.ErrSlugTaken
}
m.nextID++
a.ID = m.nextID
m.articles[a.ID] = a
@@ -244,6 +342,16 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
if update.SiteID != nil {
a.SiteID = *update.SiteID
}
if update.Slug != nil {
a.Slug = *update.Slug
}
if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, id) {
return article.Article{}, article.ErrSlugTaken
}
applySeoUpdate(&a, update)
if update.AuthorIDs != nil {
m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
}
@@ -251,6 +359,33 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
return m.withAuthors(a), nil
}
func applySeoUpdate(a *article.Article, update article.Update) {
assign := func(target *string, value *string) {
if value != nil {
*target = *value
}
}
assign(&a.Excerpt, update.Excerpt)
assign(&a.SEOTitle, update.SEOTitle)
assign(&a.SEODescription, update.SEODescription)
assign(&a.CoverImageAlt, update.CoverImageAlt)
assign(&a.SocialTitle, update.SocialTitle)
assign(&a.SocialDescription, update.SocialDescription)
assign(&a.CanonicalURL, update.CanonicalURL)
if update.CoverImageID.Set {
a.CoverImageID = update.CoverImageID.Value
}
if update.SocialImageID.Set {
a.SocialImageID = update.SocialImageID.Value
}
if update.Noindex != nil {
a.Noindex = *update.Noindex
}
if update.SEODismissed != nil {
a.SEODismissed = append([]string{}, *update.SEODismissed...)
}
}
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
return article.Article{}, err
+1 -1
View File
@@ -30,7 +30,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -24,7 +24,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+3 -3
View File
@@ -426,7 +426,7 @@ func TestArticlesPaginationApiHttp(t *testing.T) {
online bool
}{{"Banana", true}, {"apple", false}, {"Cherry", true}, {"Plâtre fin", true}}
for _, entry := range titles {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online}); err != nil {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online, Slug: uniqueTestSlug()}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
@@ -481,7 +481,7 @@ func TestArticlesPaginationApiHttp(t *testing.T) {
title string
online bool
}{{"Alpha", true}, {"Bravo", false}, {"Charlie", true}, {"Delta", false}} {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online}); err != nil {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online, Slug: uniqueTestSlug()}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
@@ -548,7 +548,7 @@ func TestArticlesPaginationApiHttp(t *testing.T) {
db := openActivityDatabase(t)
repo := persistence.NewArticleRepository(db)
for _, title := range []string{"Alpha", "Beta", "Gamma"} {
if _, err := repo.Create(domainArticle.Article{Title: title, IsOnline: true}); err != nil {
if _, err := repo.Create(domainArticle.Article{Title: title, IsOnline: true, Slug: uniqueTestSlug()}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func mustCreateCreditedArticle(t *testing.T, db *gorm.DB, title string, authorID
for _, id := range authorIDs {
bylines = append(bylines, author.Byline{ID: id})
}
created, err := persistence.NewArticleRepository(db).Create(article.Article{Title: title, Body: "Body", Authors: bylines})
created, err := persistence.NewArticleRepository(db).Create(article.Article{Title: title, Body: "Body", Authors: bylines, Slug: uniqueTestSlug()})
if err != nil {
t.Fatalf("create article: %v", err)
}
+19 -2
View File
@@ -282,6 +282,22 @@ func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
return usage, nil
}
func (m *mockAuthorRepo) FindByAvatar(imageID uint32) ([]author.Author, error) {
found := []author.Author{}
for _, id := range m.sortedIDsAscending() {
if a := m.authors[id]; a.AvatarID != nil && *a.AvatarID == imageID {
found = append(found, a)
}
}
return found, nil
}
func (m *mockAuthorRepo) sortedIDsAscending() []uint32 {
ids := m.sortedIDsDescending()
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (m *mockAuthorRepo) ClearAvatar(imageID uint32) ([]uint32, error) {
cleared := []uint32{}
for _, id := range m.sortedIDsDescending() {
@@ -342,10 +358,11 @@ func newAuthorEnv(t *testing.T) *authorEnv {
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
c.ListAuthorsUseCase = useCases.NewListAuthorsUseCase(authors, p.users)
+8 -3
View File
@@ -523,7 +523,7 @@ func TestAuthorsBackendMediaUsage(t *testing.T) {
}
})
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
t.Run("The usage lookup stays an array and later gains additive keys", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
@@ -532,8 +532,13 @@ func TestAuthorsBackendMediaUsage(t *testing.T) {
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "author") {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil || rec.Code != http.StatusOK || len(items) != 2 {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
first := items[0]
if first["id"] != float64(1) || first["title"] != "Spring release" || first["published"] != true || first["type"] != "article" {
t.Errorf("the article item lost a key: %v", first)
}
})
}
+1 -1
View File
@@ -28,7 +28,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -281,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+2 -2
View File
@@ -114,7 +114,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors()),
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
@@ -131,7 +131,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors()),
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(e.images, e.variants, e.files, e.renderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(e.images, e.variants, e.files, e.renderer, serve),
+3 -3
View File
@@ -69,7 +69,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
@@ -77,9 +77,9 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), env.articles),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors()),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()),
+1 -1
View File
@@ -736,7 +736,7 @@ func TestMediaUsage(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
want := `[{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]`
want := `[{"type": "article", "id": 1, "title": "Kitchen Renovation", "published": true, "fields": ["body"]}, {"type": "article", "id": 2, "title": "Backyard Notes", "published": false, "fields": ["body"]}]`
expectJSONEqual(t, rec.Body.String(), want)
})
+3 -3
View File
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Delete(testCtx, created.ID); err != nil {
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Delete(testCtx, 7)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, 7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
@@ -99,7 +99,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Delete(testCtx, created.ID)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
+1 -1
View File
@@ -130,7 +130,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
+1 -1
View File
@@ -46,7 +46,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -28,7 +28,7 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+3 -3
View File
@@ -132,7 +132,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
@@ -145,9 +145,9 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors()),
}
}
+268
View File
@@ -0,0 +1,268 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"net/http"
"reflect"
"strings"
"testing"
)
func TestSeoDataActivity(t *testing.T) {
t.Run("A short-value SEO edit is recorded once after the change", func(t *testing.T) {
cases := []struct {
name string
before func(a *article.Article)
body string
field string
from string
to string
}{
{"slug", func(a *article.Article) { a.Slug = "spring" }, `{"slug": "spring-2026"}`, "slug", "spring", "spring-2026"},
{"canonical_url", func(a *article.Article) {}, `{"canonical_url": "https://www.example.com/a"}`, "canonical_url", "", "https://www.example.com/a"},
{"noindex", func(a *article.Article) {}, `{"noindex": true}`, "noindex", "false", "true"},
{"cover_image", func(a *article.Article) {}, `{"cover_image_id": 5}`, "cover_image", "", "cover.png"},
{"social_image", func(a *article.Article) { a.SocialImageID = idPointer(5) }, `{"social_image_id": null}`, "social_image", "cover.png", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
c.before(&stored)
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, c.body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionArticleEdit, audit.CategoryArticles, audit.OutcomeSuccess)
assertProducerTarget(t, event, audit.TargetArticle, "8", seoSpringTitle)
if event.Source != audit.SourceWeb {
t.Errorf("source = %s", event.Source)
}
want := `[{"field":"` + c.field + `","kind":"text","before":"` + c.from + `","after":"` + c.to + `"}]`
if got := changesJSON(t, event); got != want {
t.Errorf("changes = %s, expected %s", got, want)
}
})
}
})
t.Run("No success event is recorded when the update itself fails", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.articles.fail.failNext("Update")
rec := e.patch(t, seoSpringID, `{"slug": "fresh"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
for _, event := range e.events(t) {
if event.Outcome == audit.OutcomeSuccess {
t.Errorf("a success event exists for a failed update: %+v", event)
}
}
})
t.Run("A regenerated slug is recorded like an edited one", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom")
e.patch(t, seoSpringID, `{"slug": ""}`)
if got := changesJSON(t, e.single(t)); got != `[{"field":"slug","kind":"text","before":"custom","after":"spring-release"}]` {
t.Errorf("changes = %s", got)
}
})
t.Run("Free-text SEO fields are recorded as a size change, never as text", func(t *testing.T) {
cases := []struct {
field string
before int
after int
delta int
}{
{"excerpt", 0, 120, 120},
{"seo_title", 40, 55, 15},
{"seo_description", 160, 100, -60},
{"social_title", 0, 30, 30},
{"social_description", 10, 25, 15},
{"cover_image_alt", 0, 16, 16},
}
for _, c := range cases {
t.Run(c.field, func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
beforeText, afterText := strings.Repeat("Q", c.before), strings.Repeat("Z", c.after)
switch c.field {
case "excerpt":
stored.Excerpt = beforeText
case "seo_title":
stored.SEOTitle = beforeText
case "seo_description":
stored.SEODescription = beforeText
case "social_title":
stored.SocialTitle = beforeText
case "social_description":
stored.SocialDescription = beforeText
case "cover_image_alt":
stored.CoverImageAlt = beforeText
}
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, `{"`+c.field+`": "`+afterText+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
if len(event.Changes) != 1 || event.Changes[0].Field != c.field || event.Changes[0].Kind != audit.ChangeBody || event.Changes[0].Delta == nil || *event.Changes[0].Delta != c.delta || event.Changes[0].Before != nil || event.Changes[0].After != nil {
t.Errorf("changes = %s", changesJSON(t, event))
}
assertEventsHoldNothing(t, e.audit, "ZZZZZ", "QQQQQ")
feed := e.owner(t, http.MethodGet, "/v1/activity/?category=articles", "")
if feed.Code != http.StatusOK || strings.Contains(feed.Body.String(), "ZZZZZ") || strings.Contains(feed.Body.String(), "QQQQQ") {
t.Errorf("the activity response leaks the text: %d %s", feed.Code, feed.Body.String())
}
})
}
})
t.Run("Same-length free text with different content is still a change", func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
stored.SEOTitle = "AAAAA"
e.articles.articles[seoSpringID] = stored
e.patch(t, seoSpringID, `{"seo_title": "BBBBB"}`)
event := e.single(t)
if len(event.Changes) != 1 || event.Changes[0].Field != "seo_title" || event.Changes[0].Delta == nil || *event.Changes[0].Delta != 0 {
t.Errorf("changes = %s", changesJSON(t, event))
}
assertEventsHoldNothing(t, e.audit, "AAAAA", "BBBBB")
feed := e.owner(t, http.MethodGet, "/v1/activity/?category=articles", "")
if strings.Contains(feed.Body.String(), "AAAAA") || strings.Contains(feed.Body.String(), "BBBBB") {
t.Errorf("the activity response leaks the text: %s", feed.Body.String())
}
})
t.Run("A body and SEO edit together record both", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.patch(t, seoSpringID, `{"body": "New body text", "slug": "new"}`)
event := e.single(t)
fields := []string{}
for _, change := range event.Changes {
fields = append(fields, change.Field+":"+string(change.Kind))
}
if !reflect.DeepEqual(fields, []string{"body:body", "slug:text"}) {
t.Errorf("changes = %s", changesJSON(t, event))
}
assertEventsHoldNothing(t, e.audit, "New body text")
})
t.Run("Dismissing suggestions is not an edit", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["short"]}`)
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, []string{"short"}) {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A no-op SEO edit records nothing", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
same := `{"slug": "spring-full", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true}`
rec := e.patch(t, seoSpringID, same)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A refused slug is recorded as failed with a plain reason", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Taken", siteMainID, "taken")
rec := e.patch(t, seoSpringID, `{"slug": "taken"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionArticleEdit, audit.CategoryArticles, audit.OutcomeFailed)
assertProducerFailure(t, event, seoTakenReason)
})
t.Run("Nothing is recorded when there is nothing to attribute", func(t *testing.T) {
cases := []struct {
name string
userID uint32
path string
body string
}{
{"PATCH /v1/article/999 with an SEO field", apiKeyOwnerId, "/v1/article/999", `{"excerpt": "x"}`},
{"PATCH /v1/article/abc with an SEO field", apiKeyOwnerId, "/v1/article/abc", `{"excerpt": "x"}`},
{"PATCH /v1/article/8 with a bad slug", apiKeyOwnerId, "/v1/article/8", `{"slug": "Bad Slug"}`},
{"PATCH /v1/article/8 with a 301-character excerpt", apiKeyOwnerId, "/v1/article/8", `{"excerpt": "` + strings.Repeat("a", 301) + `"}`},
{"PATCH /v1/article/8 with an unknown cover image", apiKeyOwnerId, "/v1/article/8", `{"cover_image_id": 999}`},
{"PATCH /v1/article/8 with a bad canonical url", apiKeyOwnerId, "/v1/article/8", `{"canonical_url": "http://x.example"}`},
{"PATCH /v1/article/8 with no credentials", 0, "/v1/article/8", `{"excerpt": "x"}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.as(t, c.userID, http.MethodPatch, c.path, c.body)
e.noEvent(t)
})
}
})
t.Run("A failing audit repository never changes the response", func(t *testing.T) {
healthy := newSeoEnv(t)
healthy.seedSpring()
broken := newSeoEnv(t)
broken.seedSpring()
broken.audit.probe.appendErr = errDiskFull
want := healthy.patch(t, seoSpringID, `{"slug": "new"}`)
got := broken.patch(t, seoSpringID, `{"slug": "new"}`)
if got.Code != want.Code || stripTimestamps(got.Body.String()) != stripTimestamps(want.Body.String()) || broken.stored(seoSpringID).Slug != "new" {
t.Errorf("broken %d %s, healthy %d %s", got.Code, got.Body.String(), want.Code, want.Body.String())
}
})
t.Run("The activity API lists the SEO events", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.patch(t, seoSpringID, `{"slug": "new"}`)
rec := e.owner(t, http.MethodGet, "/v1/activity/?category=articles", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"action":"article.edit"`) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
func stripTimestamps(body string) string {
return producersTimestamps.ReplaceAllString(body, "")
}
+943
View File
@@ -0,0 +1,943 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func TestSeoDataArticlePatch(t *testing.T) {
t.Run("All SEO fields are set at once", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.authors.seed(5, "Camille", "camille")
e.articles.credit(seoSpringID, 5)
body := `{"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": ["cover_alt", "short"]}`
rec := e.patch(t, seoSpringID, body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
wantResponse := map[string]any{
"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.",
"cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.",
"social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true,
"seo_dismissed": []any{"cover_alt", "short"}, "title": seoSpringTitle, "body": "Body of Spring release", "site_id": float64(siteMainID),
}
for key, want := range wantResponse {
if !reflect.DeepEqual(decoded[key], want) {
t.Errorf("%s = %v, expected %v", key, decoded[key], want)
}
}
if authors := decoded["authors"].([]any); len(authors) != 1 {
t.Errorf("authors = %v", authors)
}
stored := e.stored(seoSpringID)
if stored.Slug != "spring-2026" || stored.Excerpt != "Short teaser." || stored.SEOTitle != "Spring release notes" || stored.SEODescription != "Everything new this spring." ||
stored.CoverImageID == nil || *stored.CoverImageID != 5 || stored.CoverImageAlt != "A field of tulips" || stored.SocialTitle != "Spring is here" ||
stored.SocialDescription != "Read the notes." || stored.SocialImageID == nil || *stored.SocialImageID != 6 || stored.CanonicalURL != "https://www.example.com/spring" ||
!stored.Noindex || !reflect.DeepEqual(stored.SEODismissed, []string{"cover_alt", "short"}) {
t.Errorf("stored = %+v", stored)
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"seo_title": "New title"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
want := before
want.SEOTitle = "New title"
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, want) {
t.Errorf("after = %+v, expected %+v", after, want)
}
})
t.Run("Text fields and the flag are cleared with empty values", func(t *testing.T) {
cases := []struct {
body string
check func(a article.Article) bool
}{
{`{"excerpt": ""}`, func(a article.Article) bool { return a.Excerpt == "" }},
{`{"seo_title": ""}`, func(a article.Article) bool { return a.SEOTitle == "" }},
{`{"seo_description": ""}`, func(a article.Article) bool { return a.SEODescription == "" }},
{`{"cover_image_alt": ""}`, func(a article.Article) bool { return a.CoverImageAlt == "" }},
{`{"social_title": ""}`, func(a article.Article) bool { return a.SocialTitle == "" }},
{`{"social_description": ""}`, func(a article.Article) bool { return a.SocialDescription == "" }},
{`{"canonical_url": ""}`, func(a article.Article) bool { return a.CanonicalURL == "" }},
{`{"noindex": false}`, func(a article.Article) bool { return !a.Noindex }},
{`{"seo_dismissed": []}`, func(a article.Article) bool { return len(a.SEODismissed) == 0 }},
{`{"cover_image_id": null}`, func(a article.Article) bool { return a.CoverImageID == nil }},
{`{"social_image_id": null}`, func(a article.Article) bool { return a.SocialImageID == nil }},
}
for _, c := range cases {
t.Run(c.body, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
rec := e.patch(t, seoSpringID, c.body)
if rec.Code != http.StatusOK || !c.check(e.stored(seoSpringID)) {
t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID))
}
})
}
})
t.Run("Boundaries are accepted", func(t *testing.T) {
cases := []struct{ name, body string }{
{"excerpt 300 characters", `{"excerpt": "` + repeated("a", 300) + `"}`},
{"excerpt 300 multi-byte characters", `{"excerpt": "` + repeated("é", 300) + `"}`},
{"seo_title 120 characters", `{"seo_title": "` + repeated("a", 120) + `"}`},
{"seo_description 320 characters", `{"seo_description": "` + repeated("a", 320) + `"}`},
{"cover_image_alt 200 characters", `{"cover_image_alt": "` + repeated("a", 200) + `"}`},
{"social_title 120 characters", `{"social_title": "` + repeated("a", 120) + `"}`},
{"social_description 320 characters", `{"social_description": "` + repeated("a", 320) + `"}`},
{"slug 100 characters", `{"slug": "` + repeated("a", 100) + `"}`},
{"canonical_url with query and fragment", `{"canonical_url": "https://example.com/a?b=1#c"}`},
{"canonical_url 2048 characters", `{"canonical_url": "https://example.com/` + repeated("a", 2048-len("https://example.com/")) + `"}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, c.body)
if rec.Code != http.StatusOK {
t.Errorf("status %d: %.200s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("Fields are validated", func(t *testing.T) {
const validator = "validator"
longURL := `{"canonical_url": "https://example.com/` + repeated("a", 2049-len("https://example.com/")) + `"}`
cases := []struct {
name string
body string
status int
text string
}{
{"excerpt 301", `{"excerpt": "` + repeated("a", 301) + `"}`, 400, validator},
{"seo_title 121", `{"seo_title": "` + repeated("a", 121) + `"}`, 400, validator},
{"seo_description 321", `{"seo_description": "` + repeated("a", 321) + `"}`, 400, validator},
{"cover_image_alt 201", `{"cover_image_alt": "` + repeated("a", 201) + `"}`, 400, validator},
{"social_title 121", `{"social_title": "` + repeated("a", 121) + `"}`, 400, validator},
{"social_description 321", `{"social_description": "` + repeated("a", 321) + `"}`, 400, validator},
{"canonical_url 2049", longURL, 400, validator},
{"slug uppercase", `{"slug": "Spring"}`, 400, "invalid_slug"},
{"slug underscore", `{"slug": "spring_2026"}`, 400, "invalid_slug"},
{"slug leading hyphen", `{"slug": "-spring"}`, 400, "invalid_slug"},
{"slug double hyphen", `{"slug": "spring--2026"}`, 400, "invalid_slug"},
{"slug 101", `{"slug": "` + repeated("a", 101) + `"}`, 400, "invalid_slug"},
{"canonical http", `{"canonical_url": "http://example.com/a"}`, 422, "invalid_url"},
{"canonical relative", `{"canonical_url": "/relative"}`, 422, "invalid_url"},
{"canonical bare host", `{"canonical_url": "example.com"}`, 422, "invalid_url"},
{"canonical no host", `{"canonical_url": "https://"}`, 422, "invalid_url"},
{"unknown dismissed code", `{"seo_dismissed": ["nope"]}`, 400, validator},
{"unknown cover image", `{"cover_image_id": 999}`, 400, "image_not_found"},
{"unknown social image", `{"social_image_id": 999}`, 400, "image_not_found"},
{"cover image zero", `{"cover_image_id": 0}`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, c.body)
if c.text == validator {
expectStatusAndValidatorText(t, rec, c.status)
} else {
expectStatusAndText(t, rec, c.status, c.text)
}
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
t.Errorf("the article changed: %+v", after)
}
e.noEvent(t)
})
}
})
t.Run("A wrong type or null on other fields is refused", func(t *testing.T) {
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"slug": null}`, `{"excerpt": null}`, `{"noindex": null}`, `{"seo_dismissed": null}`, `{"noindex": "yes"}`, `{"excerpt": 5}`, `{"cover_image_id": "a"}`, `{"seo_dismissed": "x"}`} {
t.Run(body, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, body)
expectStatusAndText(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
t.Errorf("the article changed: %+v", after)
}
})
}
})
t.Run("Duplicate dismissed codes are collapsed", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["short", "cover_alt", "short"]}`)
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, []string{"short", "cover_alt"}) {
t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed)
}
})
t.Run("Every known dismissed code is accepted", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
codes := article.DismissCodes()
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["`+strings.Join(codes, `", "`)+`"]}`)
if len(codes) != 11 || rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, codes) {
t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed)
}
})
t.Run("A refused update changes nothing else", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}`)
expectStatusAndText(t, rec, http.StatusUnprocessableEntity, "invalid_url")
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
t.Errorf("the article changed: %+v", after)
}
})
t.Run("SEO fields and the other fields change together", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.authors.seed(5, "Camille", "camille")
rec := e.patch(t, seoSpringID, `{"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}`)
stored := e.stored(seoSpringID)
if rec.Code != http.StatusOK || stored.Body != "New" || stored.SiteID != 2 || stored.Excerpt != "Teaser" || !reflect.DeepEqual(e.articles.credits[seoSpringID], []uint32{5}) {
t.Errorf("status %d, stored %+v, credits %v", rec.Code, stored, e.articles.credits[seoSpringID])
}
})
t.Run("An update without SEO fields keeps them", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"body": "New"}`)
want := before
want.Body = "New"
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID), want) {
t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID))
}
})
t.Run("Any account that can edit the article edits its SEO fields", func(t *testing.T) {
callers := []struct {
name string
send func(e *seoEnv, t *testing.T) int
}{
{"the owner's jwt cookie", func(e *seoEnv, t *testing.T) int {
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
{"a verified admin's jwt cookie", func(e *seoEnv, t *testing.T) int {
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
{"a verified account with another role", func(e *seoEnv, t *testing.T) int {
return e.as(t, seoEditorID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
{"a valid API key", func(e *seoEnv, t *testing.T) int {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
}
for _, c := range callers {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
status := c.send(e, t)
if status != http.StatusOK || e.stored(seoSpringID).Excerpt != "Teaser" {
t.Errorf("status %d, excerpt %q", status, e.stored(seoSpringID).Excerpt)
}
})
}
})
t.Run("Without a verified account the article is not edited", func(t *testing.T) {
callers := []struct {
name string
userID uint32
status int
}{
{"an unverified account's jwt cookie", apiKeyQuinnId, http.StatusForbidden},
{"no credentials", 0, http.StatusUnauthorized},
}
for _, c := range callers {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
before := e.stored(seoSpringID)
rec := e.as(t, c.userID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`)
if rec.Code != c.status || !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("status %d", rec.Code)
}
e.noEvent(t)
})
}
})
t.Run("A missing article is not found", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.patch(t, 999, `{"excerpt": "x"}`)
expectStatusAndText(t, rec, http.StatusNotFound, "Article not found")
e.noEvent(t)
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/article/abc", `{"excerpt": "x"}`)
expectStatusAndText(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
e.noEvent(t)
})
t.Run("noindex is only a flag", func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
stored.IsOnline = true
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, `{"noindex": true}`)
anonymous := e.as(t, 0, http.MethodGet, "/v1/article/8", "")
if rec.Code != http.StatusOK || !e.stored(seoSpringID).IsOnline {
t.Fatalf("status %d, online %v", rec.Code, e.stored(seoSpringID).IsOnline)
}
if anonymous.Code != http.StatusOK || decodeSeoObject(t, anonymous)["noindex"] != true {
t.Errorf("anonymous read: %d %s", anonymous.Code, anonymous.Body.String())
}
})
t.Run("The cover alt is independent of the cover", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"cover_image_alt": "A field"}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["cover_image_id"] != nil || decoded["cover_image_alt"] != "A field" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
func TestSeoDataArticleReads(t *testing.T) {
allFields := []string{"slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
otherTenFields := []string{"seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
t.Run("Single-article reads carry all twelve fields", func(t *testing.T) {
requests := []struct{ method, path string }{
{http.MethodGet, "/v1/article/8"},
{http.MethodPatch, "/v1/article/8"},
{http.MethodPatch, "/v1/article/8/publish"},
{http.MethodPatch, "/v1/article/8/unpublish"},
}
for _, request := range requests {
t.Run(request.method+" "+request.path, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
body := ""
if request.method == http.MethodPatch && !strings.Contains(request.path, "publish") {
body = `{"body": "same"}`
}
rec := e.owner(t, request.method, request.path, body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
stored := e.stored(seoSpringID)
want := map[string]any{
"slug": stored.Slug, "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.",
"cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.",
"social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": []any{"cover_alt", "short"},
}
for _, key := range allFields {
if !reflect.DeepEqual(decoded[key], want[key]) {
t.Errorf("%s = %v, expected %v", key, decoded[key], want[key])
}
}
})
}
})
t.Run("A new article has the defaults", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
want := map[string]any{"slug": "spring-release", "excerpt": "", "seo_title": "", "seo_description": "", "cover_image_id": nil, "cover_image_alt": "", "social_title": "", "social_description": "", "social_image_id": nil, "canonical_url": "", "noindex": false, "seo_dismissed": []any{}}
for key, value := range want {
if !reflect.DeepEqual(decoded[key], value) {
t.Errorf("%s = %#v, expected %#v", key, decoded[key], value)
}
}
})
t.Run("The list carries the excerpt and the slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
stored := e.stored(seoSpringID)
stored.Slug = "spring-release"
e.articles.articles[seoSpringID] = stored
e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes")
rec := e.owner(t, http.MethodGet, "/v1/article/", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
items := decoded["items"].([]any)
if len(items) != 2 || decoded["total"] != float64(2) || decoded["page"] != float64(1) || decoded["size"] != float64(25) || decoded["counts"] == nil {
t.Fatalf("list = %v", decoded)
}
for _, raw := range items {
item := raw.(map[string]any)
if _, ok := item["excerpt"]; !ok {
t.Errorf("item without excerpt: %v", item)
}
if _, ok := item["slug"]; !ok {
t.Errorf("item without slug: %v", item)
}
for _, key := range otherTenFields {
if _, present := item[key]; present {
t.Errorf("item carries %s: %v", key, item)
}
}
if item["id"] == float64(seoSpringID) && (item["excerpt"] != "Short teaser." || item["slug"] != "spring-release") {
t.Errorf("article 8 item = %v", item)
}
}
})
t.Run("Anonymous readers see the SEO fields of online articles", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoOtherID)
online := e.stored(seoOtherID)
online.IsOnline = true
e.articles.articles[seoOtherID] = online
rec := e.as(t, 0, http.MethodGet, "/v1/article/9", "")
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["excerpt"] != "Short teaser." || decoded["canonical_url"] != "https://www.example.com/spring" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("An offline article is still not found publicly", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.as(t, 0, http.MethodGet, "/v1/article/8", "")
unverified := e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/8", "")
expectStatusAndText(t, rec, http.StatusNotFound, "Article not found")
expectStatusAndText(t, unverified, http.StatusNotFound, "Article not found")
})
t.Run("The list for anonymous callers shows online articles only", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
online := e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes")
online.IsOnline = true
online.Excerpt = "Leaves."
e.articles.articles[seoOtherID] = online
rec := e.as(t, 0, http.MethodGet, "/v1/article/", "")
items := decodeSeoObject(t, rec)["items"].([]any)
if rec.Code != http.StatusOK || len(items) != 1 {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
item := items[0].(map[string]any)
if item["excerpt"] != "Leaves." || item["slug"] != "autumn-notes" {
t.Errorf("item = %v", item)
}
})
}
func TestSeoDataSlugGeneration(t *testing.T) {
t.Run("The slug is generated from the title at creation", func(t *testing.T) {
for _, c := range []struct{ title, slug string }{
{"Spring release", "spring-release"},
{"Été à Paris", "ete-a-paris"},
{"Dr. O'Neil Smith", "dr-o-neil-smith"},
{"!!!", "article"},
} {
t.Run(c.title, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, c.title)+`, "body": "x"}`)
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != c.slug {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("A long title gives a slug of at most 100 characters", func(t *testing.T) {
e := newSeoEnv(t)
title := strings.TrimSpace(repeated("longword ", 22))
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`)
slug := decodeSeoObject(t, rec)["slug"].(string)
if rec.Code != http.StatusCreated || len(slug) > 100 || strings.HasSuffix(slug, "-") || !article.IsValidSlug(slug) {
t.Errorf("status %d slug %q (%d)", rec.Code, slug, len(slug))
}
})
t.Run("A generated slug that is taken on the site is de-duplicated silently", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "One", siteMainID, "spring-release")
e.seedArticle(2, "Two", siteMainID, "spring-release-2")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != "spring-release-3" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("A title without letters or digits is de-duplicated too", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "One", siteMainID, "article")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "!!!", "body": "x"}`)
if decodeSeoObject(t, rec)["slug"] != "article-2" {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("The suffix still fits the slug limit at creation", func(t *testing.T) {
e := newSeoEnv(t)
title := repeated("a", 100)
e.seedArticle(1, "One", siteMainID, title)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`)
slug := decodeSeoObject(t, rec)["slug"].(string)
if rec.Code != http.StatusCreated || len(slug) != 100 || !strings.HasSuffix(slug, "-2") {
t.Errorf("status %d slug %q", rec.Code, slug)
}
})
t.Run("The same title on another site keeps the plain slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "One", siteMainID, "spring-release")
e.sites.seed(siteShopID, "Shop", "shop", true)
defaultSite := e.sites.sites[siteMainID]
defaultSite.IsDefault = false
e.sites.sites[siteMainID] = defaultSite
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
decoded := decodeSeoObject(t, rec)
if decoded["slug"] != "spring-release" || decoded["site_id"] != float64(siteShopID) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("A new article is created on the default site, so uniqueness uses that site", func(t *testing.T) {
e := newSeoEnv(t)
e.sites.seed(siteMainID, "Main", "main", false)
e.sites.seed(siteShopID, "Shop", "shop", true)
e.seedArticle(1, "One", siteShopID, "spring-release")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
if decodeSeoObject(t, rec)["slug"] != "spring-release-2" {
t.Errorf("body %s", rec.Body.String())
}
})
}
func TestSeoDataSlugEditing(t *testing.T) {
assertConflict := func(t *testing.T, rec interface {
Result() *http.Response
}, status int, suggestion string) {
t.Helper()
response := rec.Result()
if response.StatusCode != status || response.Header.Get(seoSlugHeader) != suggestion {
t.Errorf("status %d suggestion %q, expected %d %q", response.StatusCode, response.Header.Get(seoSlugHeader), status, suggestion)
}
}
t.Run("The same slug is allowed on two sites", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
e.seedArticle(seoOtherID, "Other", siteShopID, "other")
rec := e.patch(t, seoOtherID, `{"slug": "spring"}`)
if rec.Code != http.StatusOK || e.stored(seoOtherID).Slug != "spring" {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The slug is editable at any time", func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
stored.IsOnline = true
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, `{"slug": "spring-2026"}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring-2026" || !e.stored(seoSpringID).IsOnline {
t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID))
}
})
t.Run("Keeping the own slug is not a collision", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"slug": "spring-release", "excerpt": "x"}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Excerpt != "x" {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A taken slug is refused with a suggestion", func(t *testing.T) {
for _, c := range []struct {
taken []string
suggestion string
}{
{[]string{"spring"}, "spring-2"},
{[]string{"spring", "spring-2"}, "spring-3"},
{[]string{"spring", "spring-2", "spring-3"}, "spring-4"},
} {
t.Run(strings.Join(c.taken, ","), func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
for index, slug := range c.taken {
e.seedArticle(uint32(20+index), "Taken "+slug, siteMainID, slug)
}
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, c.suggestion)
if !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("the article changed")
}
})
}
})
t.Run("The suggestion fits the slug limit", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
long := repeated("a", 100)
e.seedArticle(20, "Long", siteMainID, long)
rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`)
suggestion := rec.Result().Header.Get(seoSlugHeader)
if rec.Code != http.StatusConflict || len(suggestion) != 100 || !strings.HasSuffix(suggestion, "-2") {
t.Errorf("status %d suggestion %q", rec.Code, suggestion)
}
})
t.Run("The suggestion skips every taken suffix and still fits 100 characters", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
long := repeated("a", 100)
e.seedArticle(20, "Long", siteMainID, long)
e.seedArticle(21, "Long 2", siteMainID, long[:98]+"-2")
e.seedArticle(22, "Long 3", siteMainID, long[:98]+"-3")
rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`)
suggestion := rec.Result().Header.Get(seoSlugHeader)
if rec.Code != http.StatusConflict || suggestion != long[:98]+"-4" || len(suggestion) != 100 {
t.Errorf("status %d suggestion %q", rec.Code, suggestion)
}
})
t.Run("The suggestion ignores the article itself", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring-2")
e.seedArticle(seoOtherID, "Other", siteMainID, "spring")
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
assertConflict(t, rec, http.StatusConflict, "spring-2")
})
t.Run("The suggestion header is only set on the slug conflict", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Taken", siteMainID, "taken")
requests := []struct{ method, path, body string }{
{http.MethodPatch, "/v1/article/8", `{"slug": "fresh"}`},
{http.MethodPatch, "/v1/article/8", `{"slug": "Bad Slug"}`},
{http.MethodPatch, "/v1/article/999", `{"slug": "fresh"}`},
{http.MethodGet, "/v1/article/8", ""},
{http.MethodGet, "/v1/article/", ""},
{http.MethodPost, "/v1/article", `{"title": "Taken", "body": "x"}`},
}
for _, request := range requests {
rec := e.owner(t, request.method, request.path, request.body)
if header := rec.Result().Header.Get(seoSlugHeader); header != "" {
t.Errorf("%s %s answered %d with %s: %q", request.method, request.path, rec.Code, seoSlugHeader, header)
}
}
})
t.Run("The repository can report a race", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.articles.reportSlugRace = true
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"slug": "fresh-looking"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, "fresh-looking-2")
if !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("the article changed")
}
})
t.Run("An empty slug regenerates it from the title", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom-address")
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug)
}
})
t.Run("Regeneration follows the creation rule", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "!!!", siteMainID, "custom")
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "article" {
t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug)
}
})
t.Run("Regenerating to the slug it already has is a no-op", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
t.Errorf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("Regeneration that collides is refused, never silently suffixed", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoOtherID, "Other", siteMainID, seoSpringSlug)
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom")
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, "spring-release-2")
if e.stored(seoSpringID).Slug != "custom" {
t.Errorf("slug %q", e.stored(seoSpringID).Slug)
}
})
t.Run("Regeneration combined with a move uses the target site", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug)
rec := e.patch(t, seoSpringID, `{"site_id": 2, "slug": ""}`)
assertConflict(t, rec, http.StatusConflict, "spring-release-2")
})
t.Run("Moving to another site re-checks the slug on the target", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
e.seedArticle(seoOtherID, "Other", siteShopID, "spring")
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, "spring-2")
if stored := e.stored(seoSpringID); stored.SiteID != siteMainID || stored.Slug != "spring" {
t.Errorf("stored %+v", stored)
}
})
t.Run("Moving to a site where the slug is free keeps the slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if stored := e.stored(seoSpringID); rec.Code != http.StatusOK || stored.SiteID != 2 || stored.Slug != seoSpringSlug {
t.Errorf("status %d stored %+v", rec.Code, stored)
}
})
t.Run("Moving and changing the slug together checks the new slug on the target", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(seoOtherID, "Other", siteMainID, "elsewhere")
e.seedArticle(30, "On shop", siteShopID, "spring")
moved := e.patch(t, seoSpringID, `{"site_id": 2, "slug": "autumn"}`)
refused := e.patch(t, seoOtherID, `{"site_id": 2, "slug": "spring"}`)
if moved.Code != http.StatusOK || e.stored(seoSpringID).Slug != "autumn" {
t.Errorf("move status %d", moved.Code)
}
assertConflict(t, refused, http.StatusConflict, "spring-2")
})
t.Run("A refused move changes no SEO field", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"site_id": 2, "excerpt": "new"}`)
if rec.Code != http.StatusConflict || !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID))
}
})
t.Run("A freed slug can be reused", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "autumn")
e.seedArticle(seoOtherID, "Other", siteMainID, "spring")
deleted := e.owner(t, http.MethodDelete, "/v1/article/9", "")
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
if deleted.Code != http.StatusNoContent || rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring" {
t.Errorf("delete %d, patch %d", deleted.Code, rec.Code)
}
})
t.Run("Publishing and unpublishing never change the slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
published := e.owner(t, http.MethodPatch, "/v1/article/8/publish", "")
unpublished := e.owner(t, http.MethodPatch, "/v1/article/8/unpublish", "")
if published.Code != http.StatusOK || unpublished.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
t.Errorf("publish %d unpublish %d slug %q", published.Code, unpublished.Code, e.stored(seoSpringID).Slug)
}
})
}
func TestSeoDataSlugSuggestionUseCase(t *testing.T) {
t.Run("The suggestion is the first free suffix", func(t *testing.T) {
for taken := 0; taken < 6; taken++ {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Base", siteMainID, "spring")
for attempt := 2; attempt < 2+taken; attempt++ {
e.seedArticle(uint32(20+attempt), "Suffix "+strconv.Itoa(attempt), siteMainID, "spring-"+strconv.Itoa(attempt))
}
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
want := "spring-" + strconv.Itoa(2+taken)
if got := rec.Result().Header.Get(seoSlugHeader); got != want {
t.Errorf("with %d suffixes taken: %q, expected %q", taken, got, want)
}
}
})
t.Run("The suggestion is computed on the target site of a move", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
e.seedArticle(20, "Main also", siteMainID, "spring-2")
e.seedArticle(21, "Shop", siteShopID, "spring")
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if got := rec.Result().Header.Get(seoSlugHeader); got != "spring-2" {
t.Errorf("suggestion %q, expected the slug that is free on the shop", got)
}
})
t.Run("A suggestion lookup failure is an internal error without header", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Taken", siteMainID, "taken")
e.articles.fail.failNext("SlugTaken")
rec := e.patch(t, seoSpringID, `{"slug": "fresh"}`)
if rec.Code != http.StatusInternalServerError || rec.Result().Header.Get(seoSlugHeader) != "" {
t.Errorf("status %d", rec.Code)
}
})
}
func TestSeoDataDismissCodes(t *testing.T) {
t.Run("The dismissed codes are the eleven suggestion codes", func(t *testing.T) {
if got := fmt.Sprint(article.DismissCodes()); got != "[title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short]" {
t.Errorf("codes = %s", got)
}
})
}
+194
View File
@@ -0,0 +1,194 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/slug"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"reflect"
"strings"
"testing"
"gorm.io/gorm"
)
func legacyWithSlugs(t *testing.T, slugs map[uint32]string, rows ...seoLegacyRow) *gorm.DB {
t.Helper()
db := openPreSliceDatabase(t, rows...)
migrateSeo(t, db)
if err := db.Exec("DROP INDEX idx_articles_site_slug").Error; err != nil {
t.Fatal(err)
}
for id, value := range slugs {
if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", value).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestSeoDataCleanerBackfillRepair(t *testing.T) {
rows := func(count int) []seoLegacyRow {
result := []seoLegacyRow{}
for id := 1; id <= count; id++ {
result = append(result, seoLegacyRow{id: uint32(id), siteID: 1, title: "Row"})
}
return result
}
t.Run("Live duplicates keep the lowest id and the others are suffixed", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news", 3: "news", 4: "news-2", 5: "news"}, rows(5)...)
if err := db.Model(&entity.Article{}).Where("id = ?", 5).UpdateColumn("site_id", 2).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
want := map[uint32]string{1: "news", 2: "news-3", 3: "news-4", 4: "news-2", 5: "news"}
if got := storedSlugs(t, db); !reflect.DeepEqual(got, want) {
t.Errorf("slugs = %v", got)
}
if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
t.Error("the index is missing")
}
})
t.Run("A soft-deleted row sharing a slug with a live row is tombstoned", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news"}, rows(2)...)
if err := db.Delete(&entity.Article{}, 1).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: article.Tombstone(1), 2: "news"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("Every soft-deleted row is tombstoned whatever its slug", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "custom", 2: "", 3: "other"}, rows(3)...)
if err := db.Delete(&entity.Article{}, []uint32{1, 2}).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "#deleted-1", 2: "#deleted-2", 3: "other"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("A second run changes nothing", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news", 3: "gone"}, rows(3)...)
if err := db.Delete(&entity.Article{}, 3).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
first := storedSlugs(t, db)
migrateSeo(t, db)
if second := storedSlugs(t, db); !reflect.DeepEqual(first, second) || first[2] != "news-2" || first[3] != "#deleted-3" {
t.Errorf("first %v second %v", first, second)
}
})
}
func TestSeoDataCleanerBackfillAlgorithmIsFrozen(t *testing.T) {
titles := []string{"Café Déjà Vu", "Straße & Æon", "!!!", "Ça va?", strings.Repeat("a", 120), strings.Repeat("ab-", 40), "日本語", " Spaced Out ", "Ø Ł Ñ", "Hello, World 2024"}
legacy := []seoLegacyRow{}
for index, title := range titles {
legacy = append(legacy, seoLegacyRow{id: uint32(index + 1), siteID: 1, title: title})
}
db := openPreSliceDatabase(t, legacy...)
migrateSeo(t, db)
want := map[uint32]string{
1: "cafe-deja-vu", 2: "strasse-aeon", 3: "article", 4: "ca-va", 5: strings.Repeat("a", 100),
6: strings.TrimRight(strings.Repeat("ab-", 40)[:100], "-"), 7: "article-2", 8: "spaced-out", 9: "o-l-n", 10: "hello-world-2024",
}
if got := storedSlugs(t, db); !reflect.DeepEqual(got, want) {
t.Errorf("slugs = %v", got)
}
}
func TestSeoDataCleanerTombstone(t *testing.T) {
if article.Tombstone(7) != "#deleted-7" || slug.IsValid(article.Tombstone(7)) || slug.IsValid(article.Tombstone(123456)) {
t.Errorf("tombstone %q", article.Tombstone(7))
}
db := openPreSliceDatabase(t)
migrateSeo(t, db)
articles := persistence.NewArticleRepository(db)
created, err := articles.Create(article.Article{Title: "Gone", SiteID: 1, Slug: "gone", SEODismissed: []string{}})
if err != nil {
t.Fatal(err)
}
if err := articles.Delete(created.ID); err != nil {
t.Fatal(err)
}
if got := storedSlugs(t, db)[created.ID]; got != article.Tombstone(created.ID) {
t.Errorf("slug after delete %q", got)
}
}
func TestSeoDataCleanerEmptyValues(t *testing.T) {
t.Run("Empty SEO text and site text insert after two migrations", func(t *testing.T) {
db := openPreSliceDatabase(t)
migrateSeo(t, db)
migrateSeo(t, db)
sites := persistence.NewSiteRepository(db)
articles := persistence.NewArticleRepository(db)
created, siteErr := sites.Create(site.Site{Name: "Blank", Key: "blank", BaseURL: "https://blank.example.com", DefaultRobots: site.RobotsIndex, TitleTemplate: site.DefaultTitleTemplate})
stored, articleErr := articles.Create(article.Article{Title: "Blank", SiteID: created.ID, Slug: "blank", SEODismissed: []string{}})
if siteErr != nil || articleErr != nil {
t.Fatalf("site %v article %v", siteErr, articleErr)
}
reloaded, err := sites.Get(created.ID)
found, foundErr := articles.Get(stored.ID)
if err != nil || reloaded.SiteName != "" || reloaded.DefaultDescription != "" || foundErr != nil || found.SEOTitle != "" || found.SEODescription != "" || found.Excerpt != "" {
t.Errorf("site %+v (%v) article %+v (%v)", reloaded, err, found, foundErr)
}
})
t.Run("An empty stored title template reads as the default", func(t *testing.T) {
db := openPreSliceDatabase(t)
migrateSeo(t, db)
sites := persistence.NewSiteRepository(db)
created := mustCreateSite(t, sites, "Blank", "blank", false)
if err := db.Model(&entity.Site{}).Where("id = ?", created.ID).UpdateColumn("title_template", "").Error; err != nil {
t.Fatal(err)
}
got, err := sites.Get(created.ID)
listed, listErr := sites.List()
if err != nil || got.TitleTemplate != site.DefaultTitleTemplate || listErr != nil {
t.Fatalf("got %q %v %v", got.TitleTemplate, err, listErr)
}
for _, entry := range listed {
if entry.TitleTemplate == "" {
t.Errorf("listed site %d has an empty template", entry.ID)
}
}
})
}
func TestSeoDataCleanerSiteKeyRace(t *testing.T) {
db := openPreSliceDatabase(t)
migrateSeo(t, db)
sites := persistence.NewSiteRepository(db)
mustCreateSite(t, sites, "Shop", "shop", false)
_, err := sites.Create(site.Site{Name: "Again", Key: "shop", BaseURL: "https://again.example.com", DefaultRobots: site.RobotsIndex})
if !errors.Is(err, site.ErrSiteKeyTaken) {
t.Errorf("err = %v", err)
}
}
+353
View File
@@ -0,0 +1,353 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"log"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"testing"
)
type seoAlwaysRacingArticleRepo struct {
*mockArticleRepo
createCalls int
}
func (r *seoAlwaysRacingArticleRepo) Create(a article.Article) (article.Article, error) {
r.createCalls++
return article.Article{}, article.ErrSlugTaken
}
func TestSeoDataCleanerUsageOrdering(t *testing.T) {
t.Run("Usage lists articles then sites, each ordered by id", func(t *testing.T) {
e := newSeoEnv(t)
for _, id := range []uint32{11, 9, 12, 8, 10, 19, 15, 13, 17, 14, 18, 16} {
e.seedArticle(id, "Article", siteMainID, "article-"+strconv.Itoa(int(id)))
e.updateArticle(id, func(a *article.Article) { a.CoverImageID = idPointer(5) })
}
e.sites.seed(3, "Zeta", "zeta", true)
e.sites.seed(siteMainID, "Main", "main", false)
for _, id := range []uint32{3, siteShopID, siteMainID} {
s := e.sites.sites[id]
s.DefaultSocialImageID = idPointer(5)
e.sites.sites[id] = s
}
items := e.usage(t, 5)
got := []string{}
ids := []float64{}
for _, item := range items {
got = append(got, item["type"].(string))
ids = append(ids, item["id"].(float64))
}
wantTypes := []string{}
wantIDs := []float64{}
for id := 8; id <= 19; id++ {
wantTypes = append(wantTypes, "article")
wantIDs = append(wantIDs, float64(id))
}
wantTypes = append(wantTypes, "site", "site", "site")
wantIDs = append(wantIDs, siteMainID, siteShopID, 3)
if !reflect.DeepEqual(got, wantTypes) || !reflect.DeepEqual(ids, wantIDs) {
t.Errorf("types %v ids %v", got, ids)
}
})
}
func TestSeoDataCleanerSlugRaces(t *testing.T) {
t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) {
e := newSeoEnv(t)
racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles}
create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
_, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Title: "Racing", Body: "x"})
if !errors.Is(err, article.ErrSlugTaken) || racing.createCalls != 5 {
t.Errorf("err %v after %d attempts", err, racing.createCalls)
}
})
t.Run("A race while moving to another site suggests a free slug there", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.articles.reportSlugRace = true
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if rec.Code != http.StatusConflict || rec.Result().Header.Get(seoSlugHeader) != "spring-release-2" {
t.Errorf("status %d suggestion %q", rec.Code, rec.Result().Header.Get(seoSlugHeader))
}
})
t.Run("Moving to a site where the slug is taken conflicts", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(1, "Other", siteShopID, seoSpringSlug)
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if rec.Code != http.StatusConflict || rec.Result().Header.Get(seoSlugHeader) != "spring-release-2" || e.stored(seoSpringID).SiteID != siteMainID {
t.Errorf("status %d suggestion %q site %d", rec.Code, rec.Result().Header.Get(seoSlugHeader), e.stored(seoSpringID).SiteID)
}
})
}
func TestSeoDataCleanerCreateErrors(t *testing.T) {
t.Run("A missing default site is a 404, not a 500", func(t *testing.T) {
e := newSeoEnv(t)
e.sites.fail.failNextWith("GetDefault", site.ErrSiteNotFound)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusNotFound, apperr.SiteNotFoundErrorMessage)
})
t.Run("Exhausted slug retries are a 409", func(t *testing.T) {
e := newSeoEnv(t)
e.articles.createAlwaysRaces = true
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
})
t.Run("An unknown failure keeps the generic create message", func(t *testing.T) {
e := newSeoEnv(t)
e.articles.fail.failNext("Create")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusInternalServerError, "Failed to create article")
})
}
func TestSeoDataCleanerFreeSlugCap(t *testing.T) {
seedTaken := func(e *seoEnv, upTo int) {
e.seedArticle(1, "Spring release", siteMainID, seoSpringSlug)
for attempt := 2; attempt <= upTo; attempt++ {
e.seedArticle(uint32(attempt), "Spring release", siteMainID, seoSpringSlug+"-"+strconv.Itoa(attempt))
}
}
t.Run("The last attempt is still used", func(t *testing.T) {
e := newSeoEnv(t)
seedTaken(e, 99)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != "spring-release-100" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("Creating gives up when every candidate is taken", func(t *testing.T) {
e := newSeoEnv(t)
seedTaken(e, 100)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
if rec.Result().Header.Get(seoSlugHeader) != "" || len(e.articles.articles) != 100 {
t.Errorf("suggestion %q, %d articles", rec.Result().Header.Get(seoSlugHeader), len(e.articles.articles))
}
})
t.Run("A suggestion gives up when every candidate is taken", func(t *testing.T) {
e := newSeoEnv(t)
seedTaken(e, 100)
e.seedArticle(500, "Other", siteMainID, "other")
rec := e.patch(t, 500, `{"slug": "spring-release"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
if rec.Result().Header.Get(seoSlugHeader) != "" {
t.Errorf("suggestion %q", rec.Result().Header.Get(seoSlugHeader))
}
})
}
func TestSeoDataCleanerSiteLengthLimits(t *testing.T) {
locale35 := "en-abcdefgh-abcdefgh-abcdefgh-abcde"
locale36 := locale35 + "f"
host := func(length int) string { return "https://" + strings.Repeat("a", length-len("https://")) }
t.Run("A locale of 35 characters is the longest accepted", func(t *testing.T) {
accepted, acceptedErr := site.NormalizeLocale(&locale35)
rejected, rejectedErr := site.NormalizeLocale(&locale36)
if len(locale35) != 35 || len(locale36) != 36 || acceptedErr != nil || *accepted != locale35 || !errors.Is(rejectedErr, site.ErrInvalidLocale) || rejected != nil {
t.Errorf("accepted %v %v, rejected %v %v", accepted, acceptedErr, rejected, rejectedErr)
}
})
t.Run("A base URL of 255 characters is the longest accepted", func(t *testing.T) {
accepted, acceptedErr := site.NormalizeBaseURL(host(255))
rejected, rejectedErr := site.NormalizeBaseURL(host(256))
if acceptedErr != nil || accepted != host(255) || !errors.Is(rejectedErr, site.ErrInvalidSiteURL) || rejected != "" {
t.Errorf("accepted %d %v, rejected %q %v", len(accepted), acceptedErr, rejected, rejectedErr)
}
})
t.Run("Over-length input is a 400 over HTTP", func(t *testing.T) {
e := newSeoEnv(t)
locale := e.owner(t, http.MethodPatch, "/v1/site/2", `{"locale": "`+locale36+`"}`)
baseURL := e.owner(t, http.MethodPatch, "/v1/site/2", `{"base_url": "`+host(256)+`"}`)
created := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Long", "key": "long", "base_url": "`+host(256)+`"}`)
if locale.Code != http.StatusBadRequest || baseURL.Code != http.StatusBadRequest || created.Code != http.StatusBadRequest {
t.Errorf("locale %d (%s), base_url %d (%s), create %d (%s)", locale.Code, locale.Body.String(), baseURL.Code, baseURL.Body.String(), created.Code, created.Body.String())
}
})
}
type seoFailingCompensationSites struct{ *mockSiteRepo }
func (r seoFailingCompensationSites) AssignImage([]uint32, uint32) error {
return errors.New("sites compensation down")
}
type seoFailingCompensationAuthors struct{ *mockAuthorRepo }
func (r seoFailingCompensationAuthors) AssignAvatar([]uint32, uint32) error {
return errors.New("authors compensation down")
}
type seoFailingCompensationArticles struct{ *mockArticleRepo }
func (r seoFailingCompensationArticles) RestoreImage(article.ImageReferences, uint32) error {
return errors.New("articles compensation down")
}
type seoFlakyBodyArticles struct {
*mockArticleRepo
calls int
}
func (r *seoFlakyBodyArticles) UpdateBody(id uint32, body string) (article.Article, error) {
r.calls++
if r.calls > 1 {
return article.Article{}, errors.New("body write " + strconv.Itoa(r.calls) + " down")
}
return r.mockArticleRepo.UpdateBody(id, body)
}
func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buffer bytes.Buffer
log.SetOutput(&buffer)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
return &buffer
}
func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
t.Run("A failed delete logs every failed compensation", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.articles.fail.failNextWith("ClearImage", errors.New("clear down"))
buffer := captureLog(t)
remove := useCases.NewDeleteMediaUseCase(e.images, newMockImageStorage(), newMockVariantFileRepo(), e.audit.recorder, seoFailingCompensationSites{e.sites}, seoFailingCompensationAuthors{e.authors}, e.articles)
err := remove.Delete(t.Context(), 5)
logged := buffer.String()
if err == nil || !strings.Contains(logged, "restore site images: sites compensation down") || !strings.Contains(logged, "restore author avatars: authors compensation down") {
t.Errorf("err %v log %q", err, logged)
}
})
t.Run("A failed remove also logs the article restore", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.images.fail.failNextWith("Delete", errors.New("delete down"))
buffer := captureLog(t)
remove := useCases.NewDeleteMediaUseCase(e.images, newMockImageStorage(), newMockVariantFileRepo(), e.audit.recorder, seoFailingCompensationSites{e.sites}, seoFailingCompensationAuthors{e.authors}, seoFailingCompensationArticles{e.articles})
err := remove.Delete(t.Context(), 5)
logged := buffer.String()
if err == nil || !strings.Contains(logged, "restore site images: sites compensation down") || !strings.Contains(logged, "restore author avatars: authors compensation down") || !strings.Contains(logged, "restore article images: articles compensation down") {
t.Errorf("err %v log %q", err, logged)
}
})
t.Run("A failed merge logs the body it could not restore", func(t *testing.T) {
e := newSeoEnv(t)
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "One", siteMainID, "one")
e.seedArticle(9, "Two", siteMainID, "two")
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) })
buffer := captureLog(t)
flaky := &seoFlakyBodyArticles{mockArticleRepo: e.articles}
merge := useCases.NewMergeMediaUseCase(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
_, err := merge.Merge(t.Context(), 5, []uint32{6})
if err == nil || !strings.Contains(buffer.String(), "restore article body 8: body write 3 down") {
t.Errorf("err %v log %q", err, buffer.String())
}
})
}
type seoCaseInsensitiveBodyArticles struct{ *mockArticleRepo }
func (r seoCaseInsensitiveBodyArticles) FindByBodyContaining(filename string) ([]article.Article, error) {
found := []article.Article{}
for _, id := range r.sortedIds() {
stored := r.articles[id]
if strings.Contains(strings.ToLower(stored.Body), strings.ToLower(filename)) {
found = append(found, stored)
}
}
return found, nil
}
func TestSeoDataCleanerCaseInsensitiveBodyMatches(t *testing.T) {
seed := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Exact", siteMainID, "exact")
e.seedArticle(9, "Shouting", siteMainID, "shouting")
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.updateArticle(9, func(a *article.Article) { a.Body = strings.ToUpper(e.bodyUsing(6)) })
}
t.Run("Merge only counts the articles it rewrote", func(t *testing.T) {
e := newSeoEnv(t)
seed(e)
before := e.stored(9)
merge := useCases.NewMergeMediaUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
result, err := merge.Merge(t.Context(), 5, []uint32{6})
if err != nil || result.ArticlesUpdated != 1 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{8}) || !reflect.DeepEqual(e.stored(9), before) {
t.Errorf("result %+v, %v", result, err)
}
})
t.Run("Usage only lists real body references", func(t *testing.T) {
e := newSeoEnv(t)
seed(e)
usage := useCases.NewGetMediaUsageUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, e.sites, e.authors)
items, err := usage.Usage(6)
if err != nil || len(items) != 1 || items[0].ID != 8 {
t.Errorf("items %+v, %v", items, err)
}
})
}
+479
View File
@@ -0,0 +1,479 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"reflect"
"strconv"
"testing"
)
func (e *seoEnv) bodyUsing(imageID uint32) string {
return `<img src="` + producersBannerPath + strconv.Itoa(int(imageID)) + `.png">`
}
func (e *seoEnv) updateArticle(id uint32, change func(a *article.Article)) {
stored := e.articles.articles[id]
change(&stored)
e.articles.articles[id] = stored
}
func (e *seoEnv) usedIn(t *testing.T, imageID uint32) int {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/?size=100", "")
var listed struct {
Items []struct {
ID uint32 `json:"id"`
UsedIn int `json:"used_in"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
for _, item := range listed.Items {
if item.ID == imageID {
return item.UsedIn
}
}
t.Fatalf("image %d is not listed", imageID)
return 0
}
func (e *seoEnv) unusedListing(t *testing.T) []uint32 {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/?filter=unused&size=100", "")
var listed struct {
Items []struct {
ID uint32 `json:"id"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
ids := []uint32{}
for _, item := range listed.Items {
ids = append(ids, item.ID)
}
return ids
}
func (e *seoEnv) usage(t *testing.T, imageID uint32) []map[string]any {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/"+strconv.Itoa(int(imageID))+"/usage", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return items
}
func TestSeoDataMediaUsage(t *testing.T) {
t.Run("A cover or social image counts as used", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
items := e.usage(t, 5)
if len(items) != 1 || items[0]["id"] != float64(seoSpringID) || e.usedIn(t, 5) != 1 || len(e.unusedListing(t)) != 1 {
t.Errorf("usage %v, used_in %d, unused %v", items, e.usedIn(t, 5), e.unusedListing(t))
}
})
t.Run("Each reference kind counts", func(t *testing.T) {
cases := []struct {
name string
prep func(e *seoEnv)
listed bool
}{
{"the cover of an article", func(e *seoEnv) {
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
}, false},
{"the social image of an article", func(e *seoEnv) {
e.updateArticle(seoSpringID, func(a *article.Article) { a.SocialImageID = idPointer(5) })
}, false},
{"the default social image of a site", func(e *seoEnv) {
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
}, false},
{"the avatar of an author", func(e *seoEnv) {
e.authors.seed(2, "Camille", "camille")
a := e.authors.authors[2]
a.AvatarID = idPointer(5)
e.authors.authors[2] = a
}, false},
{"a file name in an article body", func(e *seoEnv) { e.updateArticle(seoSpringID, func(a *article.Article) { a.Body = e.bodyUsing(5) }) }, false},
{"nothing", func(e *seoEnv) {}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
c.prep(e)
unused := e.unusedListing(t)
listed := false
for _, id := range unused {
if id == 5 {
listed = true
}
}
if listed != c.listed {
t.Errorf("unused = %v", unused)
}
})
}
})
t.Run("An article that uses the image twice counts once", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) {
a.Body = e.bodyUsing(5)
a.CoverImageID = idPointer(5)
a.SocialImageID = idPointer(5)
})
if used := e.usedIn(t, 5); used != 1 {
t.Errorf("used_in = %d", used)
}
})
t.Run("The media list sums every kind", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "Cover", siteMainID, "cover")
e.seedArticle(2, "Social", siteMainID, "social")
e.seedArticle(3, "Body", siteMainID, "body")
e.updateArticle(1, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.updateArticle(2, func(a *article.Article) { a.SocialImageID = idPointer(5) })
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5) })
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
if used := e.usedIn(t, 5); used != 5 {
t.Errorf("used_in = %d", used)
}
})
t.Run("Usage lists articles, authors and sites", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(3, "Body article", siteMainID, "body-article")
e.seedArticle(8, "Cover article", siteMainID, "cover-article")
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5); a.IsOnline = true })
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
main := e.sites.sites[siteMainID]
main.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteMainID] = main
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5/usage", "")
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"]},
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"]},
{"type": "author", "id": 2, "title": "Camille", "fields": ["avatar"]},
{"type": "site", "id": 1, "title": "Main", "fields": ["default_social_image"]}]`
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
expectJSONEqual(t, rec.Body.String(), want)
})
t.Run("The change is additive for existing consumers", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(3, "Body article", siteMainID, "body-article")
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5); a.IsOnline = true })
items := e.usage(t, 5)
keys := []string{}
for key := range items[0] {
keys = append(keys, key)
}
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 5 {
t.Errorf("item = %v", items)
}
})
t.Run("An article using an image in several places is one item with all fields", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) {
a.Body = e.bodyUsing(5)
a.CoverImageID = idPointer(5)
a.SocialImageID = idPointer(5)
})
items := e.usage(t, 5)
if len(items) != 1 || !reflect.DeepEqual(items[0]["fields"], []any{"body", "cover", "social"}) {
t.Errorf("items = %v", items)
}
})
t.Run("An unused image has an empty usage array", func(t *testing.T) {
e := newSeoEnv(t)
e.seedBanner(9, "unused.png", "hash-unused")
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/9/usage", "")
expectStatusAndText(t, rec, http.StatusOK, "[]")
})
t.Run("An unknown image is not found", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/999/usage", "")
expectStatusAndText(t, rec, http.StatusNotFound, "Image not found")
})
t.Run("Offline articles are listed with published false", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
items := e.usage(t, 5)
if len(items) != 1 || items[0]["published"] != false {
t.Errorf("items = %v", items)
}
})
}
func TestSeoDataMediaDelete(t *testing.T) {
seedReferences := func(e *seoEnv) {
e.seedArticle(8, "Cover", siteMainID, "cover")
e.seedArticle(9, "Social", siteMainID, "social")
e.updateArticle(8, func(a *article.Article) {
a.CoverImageID = idPointer(5)
a.CoverImageAlt = "A field"
a.Body = "Body 8"
})
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(5); a.Body = "Body 9" })
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
}
t.Run("Deleting an image in use clears the references and is never refused", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if e.stored(8).CoverImageID != nil || e.stored(9).SocialImageID != nil || e.stored(8).CoverImageAlt != "A field" || e.stored(8).Body != "Body 8" || e.stored(9).Body != "Body 9" {
t.Errorf("articles = %+v %+v", e.stored(8), e.stored(9))
}
if e.authors.authors[2].AvatarID != nil || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
t.Errorf("avatar or site reference left")
}
})
t.Run("A failed delete leaves every reference in place", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
e.images.failDeleteFor = map[uint32]error{5: errors.New("storage down")}
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
cover, social := e.stored(8).CoverImageID, e.stored(9).SocialImageID
if cover == nil || *cover != 5 || social == nil || *social != 5 {
t.Errorf("article references = %v %v", cover, social)
}
if e.authors.authors[2].AvatarID == nil || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("avatar or site reference lost")
}
})
t.Run("A failure clearing the article references restores the other ones", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
e.articles.fail.failNext("ClearImage")
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError || e.authors.authors[2].AvatarID == nil || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("status %d", rec.Code)
}
})
}
func TestSeoDataMediaMerge(t *testing.T) {
seedDuplicates := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Cover", siteMainID, "cover")
e.seedArticle(9, "Social", siteMainID, "social")
}
mergeBody := `{"keep_id": 5, "merge_ids": [6]}`
t.Run("Merging copies repoints article cover and social images", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["articles_updated"] != float64(2) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if *e.stored(8).CoverImageID != 5 || *e.stored(9).SocialImageID != 5 {
t.Errorf("references = %v %v", e.stored(8).CoverImageID, e.stored(9).SocialImageID)
}
})
t.Run("The merge result lists the updated article ids", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
result, err := merge.Merge(testCtx, 5, []uint32{6})
if err != nil || result.ArticlesUpdated != 2 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{8, 9}) {
t.Errorf("result %+v, %v", result, err)
}
})
t.Run("An article rewritten in body and cover counts once", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6); a.Body = e.bodyUsing(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
if decodeSeoObject(t, rec)["articles_updated"] != float64(1) || *e.stored(8).CoverImageID != 5 || e.stored(8).Body != e.bodyUsing(5) {
t.Errorf("status %d body %s stored %+v", rec.Code, rec.Body.String(), e.stored(8))
}
})
t.Run("Authors and sites keep being repointed", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(6)
e.authors.authors[2] = author
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(6)
e.sites.sites[siteShopID] = shop
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
decoded := decodeSeoObject(t, rec)
if decoded["authors_updated"] != float64(1) || decoded["sites_updated"] != float64(1) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("A failed repoint leaves the bodies as they were", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.articles.fail.failNext("RepointImage")
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
if rec.Code != http.StatusInternalServerError || e.stored(8).Body != e.bodyUsing(6) {
t.Errorf("status %d body %q", rec.Code, e.stored(8).Body)
}
})
}
func TestSeoDataMediaPurge(t *testing.T) {
seedGroup := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Cover", siteMainID, "cover")
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
}
t.Run("Purge repoints articles the same way", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.updateArticle(8, func(a *article.Article) { a.SocialImageID = idPointer(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["articles_updated"] != float64(1) || *e.stored(8).SocialImageID != 5 || *e.stored(8).CoverImageID != 5 {
t.Errorf("status %d body %s stored %+v", rec.Code, rec.Body.String(), e.stored(8))
}
})
t.Run("A failed purge group leaves covers pointing at existing images", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
e.images.failDeleteFor = map[uint32]error{6: errors.New("storage down"), 5: errors.New("storage down")}
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["failed_groups"] != float64(1) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
for _, stored := range e.articles.articles {
for _, reference := range []*uint32{stored.CoverImageID, stored.SocialImageID} {
if reference == nil {
continue
}
if _, exists := e.images.images[*reference]; !exists {
t.Errorf("article %d points at the missing image %d", stored.ID, *reference)
}
}
}
})
t.Run("Cover and social usage counts in the purge preview", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
rec := e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/purge-preview", "")
var preview struct {
Groups int `json:"groups"`
Articles int `json:"articles"`
List []struct {
KeepID uint32 `json:"keep_id"`
UsedIn int `json:"used_in"`
} `json:"list"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &preview); err != nil || rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if preview.Groups != 1 || preview.Articles != 0 || len(preview.List) != 1 || preview.List[0].KeepID != 6 || preview.List[0].UsedIn != 1 {
t.Errorf("preview = %+v", preview)
}
})
}
+435
View File
@@ -0,0 +1,435 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"reflect"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
type seoPreSliceArticle struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (seoPreSliceArticle) TableName() string { return "articles" }
type seoPreSliceSite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:100;not null"`
Key string `gorm:"column:site_key;size:32;not null;uniqueIndex"`
BaseURL string `gorm:"size:255;not null"`
DefaultSocialImageID *uint32
DefaultRobots string `gorm:"size:16;not null"`
Locale *string `gorm:"size:35"`
IsDefault bool `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (seoPreSliceSite) TableName() string { return "sites" }
type seoLegacyRow struct {
id uint32
siteID uint32
title string
slug string
}
func openPreSliceDatabase(t *testing.T, rows ...seoLegacyRow) *gorm.DB {
t.Helper()
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&seoPreSliceArticle{}); err != nil {
t.Fatalf("pre-slice migrate: %v", err)
}
for _, row := range rows {
if err := db.Create(&seoPreSliceArticle{ID: row.id, Title: row.title, SiteID: row.siteID, Body: "Body"}).Error; err != nil {
t.Fatalf("seed %d: %v", row.id, err)
}
}
return db
}
func migrateSeo(t *testing.T, db *gorm.DB) {
t.Helper()
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
}
func storedSlugs(t *testing.T, db *gorm.DB) map[uint32]string {
t.Helper()
var rows []entity.Article
if err := db.Unscoped().Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("read articles: %v", err)
}
slugs := map[uint32]string{}
for _, row := range rows {
slugs[row.ID] = row.Slug
}
return slugs
}
func TestSeoDataMigrationBackfill(t *testing.T) {
t.Run("Every existing article gets a slug from its title", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "Spring release"}, seoLegacyRow{id: 2, siteID: 1, title: "Autumn notes"})
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "spring-release", 2: "autumn-notes"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("Conflicts get suffixes in id order within a site", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}, seoLegacyRow{id: 3, siteID: 1, title: "News"})
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "news", 2: "news-2", 3: "news-3"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("A suffix skips slugs that exist", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News 2"}, seoLegacyRow{id: 3, siteID: 1, title: "News"})
migrateSeo(t, db)
got := storedSlugs(t, db)
if got[1] != "news" || got[2] != "news-2" || got[3] != "news-3" {
t.Errorf("slugs = %v", got)
}
})
t.Run("Slugs are unique per site, not globally", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 2, title: "News"})
migrateSeo(t, db)
if got := storedSlugs(t, db); got[1] != "news" || got[2] != "news" {
t.Errorf("slugs = %v", got)
}
})
t.Run("A title with no letter or digit becomes article, suffixed on conflict", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 5, siteID: 1, title: "!!!"}, seoLegacyRow{id: 6, siteID: 1, title: "???"}, seoLegacyRow{id: 7, siteID: 1, title: "..."})
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{5: "article", 6: "article-2", 7: "article-3"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("Long titles stay within 100 characters", func(t *testing.T) {
title := strings.Repeat("a", 150)
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: title}, seoLegacyRow{id: 2, siteID: 1, title: title}, seoLegacyRow{id: 3, siteID: 1, title: title})
migrateSeo(t, db)
seen := map[string]bool{}
for id, slug := range storedSlugs(t, db) {
if len(slug) > 100 || !article.IsValidSlug(slug) || seen[slug] {
t.Errorf("article %d slug %q (%d)", id, slug, len(slug))
}
seen[slug] = true
}
})
t.Run("The migration is idempotent", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"})
migrateSeo(t, db)
first := storedSlugs(t, db)
migrateSeo(t, db)
if second := storedSlugs(t, db); !reflect.DeepEqual(first, second) {
t.Errorf("first %v, second %v", first, second)
}
})
t.Run("An edited slug survives a restart", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"})
migrateSeo(t, db)
if err := db.Model(&entity.Article{}).Where("id = ?", 1).UpdateColumn("slug", "custom").Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
if got := storedSlugs(t, db)[1]; got != "custom" {
t.Errorf("slug = %q", got)
}
})
t.Run("A database with a partially backfilled table completes", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}, seoLegacyRow{id: 3, siteID: 1, title: "Other"})
migrateSeo(t, db)
if err := db.Exec("DROP INDEX idx_articles_site_slug").Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&entity.Article{}).Where("id = ?", 2).UpdateColumn("slug", "").Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&entity.Article{}).Where("id = ?", 1).UpdateColumn("slug", "kept").Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
got := storedSlugs(t, db)
if got[1] != "kept" || got[3] != "other" || got[2] != "news" {
t.Errorf("slugs = %v", got)
}
})
t.Run("Soft-deleted legacy articles get a slug that frees the title", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"})
if err := db.Delete(&seoPreSliceArticle{}, 1).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
got := storedSlugs(t, db)
if got[2] != "news" || got[1] != article.DeletedSlug+"1" {
t.Errorf("slugs = %v", got)
}
})
t.Run("New site columns get defaults", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&seoPreSliceSite{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&seoPreSliceSite{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index", IsDefault: true}).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
migrateSeo(t, db)
sites := storedSites(t, db)
if len(sites) != 1 || sites[0].SiteName != "" || sites[0].TitleTemplate != "%title% | %site%" || sites[0].DefaultDescription != "" {
t.Errorf("sites = %+v", sites)
}
})
t.Run("Existing articles get neutral SEO defaults", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"})
migrateSeo(t, db)
found, err := persistence.NewArticleRepository(db).Get(1)
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt}
if err != nil || !reflect.DeepEqual(found, want) {
t.Errorf("found %+v, %v", found, err)
}
})
t.Run("The migration creates the per-site unique index", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"})
migrateSeo(t, db)
if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
t.Fatal("the index is missing")
}
duplicate := entity.Article{Title: "Again", SiteID: 1, Slug: "news"}
if err := db.Create(&duplicate).Error; err == nil {
t.Error("a duplicate (site_id, slug) was accepted")
}
})
}
func TestSeoDataArticleRepositoryContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
cases := []struct {
name string
run func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32)
}{
{"every new column round-trips with null image ids and the dismissed list", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
images := newContractRepositories(db)
cover := mustCreateImage(t, images, "cover.png", "hash-1")
social := mustCreateImage(t, images, "social.png", "hash-2")
created, err := articles.Create(article.Article{Title: "Spring", Body: "Body", SiteID: siteID, Slug: "spring", Excerpt: "Teaser", SEOTitle: "SEO", SEODescription: "Description", CoverImageID: &cover.ID, CoverImageAlt: "Alt", SocialTitle: "Social", SocialDescription: "Social description", SocialImageID: &social.ID, CanonicalURL: "https://example.com/a", Noindex: true, SEODismissed: []string{"short", "cover_alt"}})
plain, plainErr := articles.Create(article.Article{Title: "Plain", Body: "Body", SiteID: siteID, Slug: "plain"})
read, readErr := articles.Get(created.ID)
readPlain, _ := articles.Get(plain.ID)
if err != nil || readErr != nil || plainErr != nil {
t.Fatalf("errors: %v %v %v", err, readErr, plainErr)
}
if read.Slug != "spring" || read.Excerpt != "Teaser" || read.SEOTitle != "SEO" || read.SEODescription != "Description" || read.CoverImageID == nil || *read.CoverImageID != cover.ID || read.CoverImageAlt != "Alt" ||
read.SocialTitle != "Social" || read.SocialDescription != "Social description" || read.SocialImageID == nil || *read.SocialImageID != social.ID || read.CanonicalURL != "https://example.com/a" || !read.Noindex ||
!reflect.DeepEqual(read.SEODismissed, []string{"short", "cover_alt"}) {
t.Errorf("read = %+v", read)
}
if readPlain.CoverImageID != nil || readPlain.SocialImageID != nil || readPlain.Noindex || readPlain.SEODismissed == nil || len(readPlain.SEODismissed) != 0 {
t.Errorf("plain = %+v", readPlain)
}
}},
{"a second article with the same site and slug is refused, another site is allowed", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
sites := persistence.NewSiteRepository(db)
other := mustCreateSite(t, sites, "Shop", "shop", false)
if _, err := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"}); err != nil {
t.Fatal(err)
}
_, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"})
_, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, Slug: "spring"})
rawDuplicate := db.Create(&entity.Article{Title: "Four", SiteID: siteID, Slug: "spring"}).Error
if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil {
t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate)
}
}},
{"update applies the SEO fields, clears the image ids and refuses a taken slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
images := newContractRepositories(db)
cover := mustCreateImage(t, images, "cover.png", "hash-1")
first, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "one"})
second, _ := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "two"})
text, flag, codes := "Teaser", true, []string{"short"}
updated, err := articles.Update(first.ID, article.Update{Excerpt: &text, Noindex: &flag, SEODismissed: &codes, CoverImageID: article.Nullable[uint32]{Set: true, Value: &cover.ID}})
cleared, clearErr := articles.Update(first.ID, article.Update{CoverImageID: article.Nullable[uint32]{Set: true}})
taken := "two"
_, conflict := articles.Update(first.ID, article.Update{Slug: &taken})
own := "two"
_, keeping := articles.Update(second.ID, article.Update{Slug: &own})
if err != nil || updated.Excerpt != "Teaser" || !updated.Noindex || !reflect.DeepEqual(updated.SEODismissed, codes) || updated.CoverImageID == nil {
t.Fatalf("updated = %+v, %v", updated, err)
}
if clearErr != nil || cleared.CoverImageID != nil || cleared.Excerpt != "Teaser" {
t.Errorf("cleared = %+v, %v", cleared, clearErr)
}
if !errors.Is(conflict, article.ErrSlugTaken) || keeping != nil {
t.Errorf("conflict %v, keeping %v", conflict, keeping)
}
}},
{"moving an article re-checks its slug on the target site", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
other := mustCreateSite(t, persistence.NewSiteRepository(db), "Shop", "shop", false)
moving, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
if _, err := articles.Create(article.Article{Title: "Two", SiteID: other.ID, Slug: "spring"}); err != nil {
t.Fatal(err)
}
_, err := articles.Update(moving.ID, article.Update{SiteID: &other.ID})
stored, _ := articles.Get(moving.ID)
if !errors.Is(err, article.ErrSlugTaken) || stored.SiteID != siteID {
t.Errorf("err %v stored %+v", err, stored)
}
}},
{"slug taken ignores the article itself and a deleted article frees its slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
own, ownErr := articles.SlugTaken(siteID, "spring", created.ID)
other, otherErr := articles.SlugTaken(siteID, "spring", 0)
deleteErr := articles.Delete(created.ID)
afterDelete, afterErr := articles.SlugTaken(siteID, "spring", 0)
_, recreated := articles.Create(article.Article{Title: "Again", SiteID: siteID, Slug: "spring"})
if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil {
t.Errorf("own %v other %v delete %v after %v recreated %v", own, other, deleteErr, afterDelete, recreated)
}
if err := articles.Delete(created.ID); !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("second delete: %v", err)
}
}},
{"image references are found, cleared, restored and repointed", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
images := newContractRepositories(db)
kept := mustCreateImage(t, images, "kept.png", "hash-a")
copied := mustCreateImage(t, images, "copy.png", "hash-a")
covered, _ := articles.Create(article.Article{Title: "Covered", SiteID: siteID, Slug: "covered", CoverImageID: &copied.ID})
shared, _ := articles.Create(article.Article{Title: "Shared", SiteID: siteID, Slug: "shared", CoverImageID: &copied.ID, SocialImageID: &copied.ID})
articles.Create(article.Article{Title: "Unrelated", SiteID: siteID, Slug: "unrelated"})
found, findErr := articles.FindByImage(copied.ID)
repointed, repointErr := articles.RepointImage([]uint32{copied.ID}, kept.ID)
references, clearErr := articles.ClearImage(kept.ID)
afterClear, _ := articles.Get(shared.ID)
restoreErr := articles.RestoreImage(references, kept.ID)
afterRestore, _ := articles.Get(shared.ID)
if findErr != nil || len(found) != 2 {
t.Fatalf("found %+v, %v", found, findErr)
}
if repointErr != nil || !reflect.DeepEqual(repointed, []uint32{covered.ID, shared.ID}) {
t.Fatalf("repointed %v, %v", repointed, repointErr)
}
if clearErr != nil || !reflect.DeepEqual(references.Cover, []uint32{covered.ID, shared.ID}) || !reflect.DeepEqual(references.Social, []uint32{shared.ID}) || afterClear.CoverImageID != nil || afterClear.SocialImageID != nil {
t.Errorf("references %+v, %v, after %+v", references, clearErr, afterClear)
}
if restoreErr != nil || afterRestore.CoverImageID == nil || *afterRestore.CoverImageID != kept.ID || afterRestore.SocialImageID == nil || *afterRestore.SocialImageID != kept.ID {
t.Errorf("restore %v, after %+v", restoreErr, afterRestore)
}
}},
{"the site repository stores and updates the SEO columns", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
sites := persistence.NewSiteRepository(db)
created, err := sites.Create(site.Site{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index", SiteName: "Renew Shop", TitleTemplate: "%title% - %site%", DefaultDescription: "A shop."})
created.SiteName = "Renew"
updated, updateErr := sites.Update(created)
seeded, _ := sites.GetDefault()
if err != nil || updateErr != nil || updated.SiteName != "Renew" || updated.TitleTemplate != "%title% - %site%" || updated.DefaultDescription != "A shop." {
t.Errorf("updated %+v, %v %v", updated, err, updateErr)
}
if seeded.TitleTemplate != "%title% | %site%" {
t.Errorf("seeded template = %q", seeded.TitleTemplate)
}
}},
{"the author repository finds the authors using an avatar", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
authors := persistence.NewAuthorRepository(db)
image := mustCreateImage(t, newContractRepositories(db), "avatar.png", "hash-avatar")
withAvatar, _ := authors.Create(authorWithAvatar("Camille", "camille", &image.ID))
authors.Create(authorWithAvatar("Other", "other", nil))
found, err := authors.FindByAvatar(image.ID)
if err != nil || len(found) != 1 || found[0].ID != withAvatar.ID {
t.Errorf("found %+v, %v", found, err)
}
}},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := backend.open(t)
defaultSite, err := persistence.NewSiteRepository(db).GetDefault()
if err != nil {
t.Fatal(err)
}
c.run(t, db, persistence.NewArticleRepository(db), defaultSite.ID)
})
}
})
}
}
func authorWithAvatar(name string, slug string, avatarID *uint32) author.Author {
return author.Author{Name: name, Slug: slug, AvatarID: avatarID, SocialLinks: []author.SocialLink{}}
}
+334
View File
@@ -0,0 +1,334 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"net/http"
"reflect"
"strings"
"testing"
)
func TestSeoDataSiteFields(t *testing.T) {
const validator = "validator"
validSite := func(extra string) string {
return `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"` + extra + `}`
}
t.Run("The owner sets the site SEO fields", func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.sites[siteShopID]
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got := decodeSiteBody(t, rec)
want := before
want.SiteName, want.TitleTemplate, want.DefaultDescription = "Renew Shop", "%title% - %site%", "A shop about renewal."
if !reflect.DeepEqual(got, want) || e.sites.sites[siteShopID].SiteName != "Renew Shop" {
t.Errorf("got %+v, expected %+v", got, want)
}
})
t.Run("A site created without the new fields has the defaults", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`)
created := decodeSiteBody(t, rec)
if rec.Code != http.StatusCreated || created.SiteName != "" || created.TitleTemplate != "%title% | %site%" || created.DefaultDescription != "" {
t.Errorf("status %d site %+v", rec.Code, created)
}
})
t.Run("A site is created with the new fields", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(`, "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."`))
created := decodeSiteBody(t, rec)
stored := e.sites.sites[created.ID]
if rec.Code != http.StatusCreated || created.SiteName != "Renew Shop" || created.TitleTemplate != "%title% - %site%" || created.DefaultDescription != "A shop about renewal." {
t.Fatalf("status %d site %+v", rec.Code, created)
}
if stored.SiteName != "Renew Shop" || stored.TitleTemplate != "%title% - %site%" || stored.DefaultDescription != "A shop about renewal." {
t.Errorf("stored %+v", stored)
}
})
t.Run("Creation validates the new fields like editing", func(t *testing.T) {
cases := []struct {
name string
extra string
status int
text string
}{
{"template with title", `, "title_template": "%title%"`, 201, ""},
{"template without title", `, "title_template": "just text"`, 400, "invalid_title_template"},
{"template with site only", `, "title_template": "%site%"`, 400, "invalid_title_template"},
{"empty template", `, "title_template": ""`, 400, "invalid_title_template"},
{"template 201", `, "title_template": "%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
{"description 320", `, "default_description": "` + strings.Repeat("a", 320) + `"`, 201, ""},
{"description 321", `, "default_description": "` + strings.Repeat("a", 321) + `"`, 400, validator},
{"site name 100", `, "site_name": "` + strings.Repeat("a", 100) + `"`, 201, ""},
{"site name 101", `, "site_name": "` + strings.Repeat("a", 101) + `"`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
sitesBefore := len(e.sites.sites)
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(c.extra))
switch {
case c.status == http.StatusCreated:
if rec.Code != c.status {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
case c.text == validator:
expectStatusAndValidatorText(t, rec, c.status)
default:
expectStatusAndText(t, rec, c.status, c.text)
}
if c.status != http.StatusCreated && len(e.sites.sites) != sitesBefore {
t.Errorf("a site was added")
}
})
}
})
t.Run("The list carries the new fields", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "")
var items []map[string]any
for _, item := range decodeSiteList(t, rec) {
items = append(items, map[string]any{"site_name": item.SiteName, "title_template": item.TitleTemplate})
}
if rec.Code != http.StatusOK || len(items) != 2 || !strings.Contains(rec.Body.String(), `"site_name":""`) || !strings.Contains(rec.Body.String(), `"title_template":"%title% | %site%"`) || !strings.Contains(rec.Body.String(), `"default_description":""`) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newSeoEnv(t)
shop := e.sites.sites[siteShopID]
shop.TitleTemplate, shop.DefaultDescription = "%title% :: %site%", "Kept."
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew"}`)
want := shop
want.SiteName = "Renew"
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
t.Errorf("status %d stored %+v", rec.Code, e.sites.sites[siteShopID])
}
})
t.Run("The template must contain %title%", func(t *testing.T) {
cases := []struct {
name string
template string
status int
text string
}{
{"title only", `"%title%"`, 200, ""},
{"title and site", `"%title% | %site%"`, 200, ""},
{"site first", `"%site% - %title%"`, 200, ""},
{"prefix", `"News: %title%"`, 200, ""},
{"site only", `"%site%"`, 400, "invalid_title_template"},
{"plain text", `"just text"`, 400, "invalid_title_template"},
{"upper case placeholder", `"%TITLE%"`, 400, "invalid_title_template"},
{"empty", `""`, 400, "invalid_title_template"},
{"201 characters", `"%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"title_template": `+c.template+`}`)
switch {
case c.status == http.StatusOK:
if rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
case c.text == validator:
expectStatusAndValidatorText(t, rec, c.status)
default:
expectStatusAndText(t, rec, c.status, c.text)
}
})
}
})
t.Run("Other site SEO fields are validated", func(t *testing.T) {
cases := []struct {
name string
body string
status int
}{
{"description 321", `{"default_description": "` + strings.Repeat("a", 321) + `"}`, 400},
{"description 320", `{"default_description": "` + strings.Repeat("a", 320) + `"}`, 200},
{"description empty", `{"default_description": ""}`, 200},
{"site name 101", `{"site_name": "` + strings.Repeat("a", 101) + `"}`, 400},
{"site name empty", `{"site_name": ""}`, 200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != c.status {
t.Errorf("status %d: %.120s", rec.Code, rec.Body.String())
}
if c.status == http.StatusBadRequest {
expectStatusAndValidatorText(t, rec, c.status)
}
})
}
})
t.Run("A refused site update changes no other field", func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.sites[siteShopID]
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "title_template": "nope"}`)
expectStatusAndText(t, rec, http.StatusBadRequest, "invalid_title_template")
if e.sites.sites[siteShopID].Name != before.Name {
t.Errorf("the name changed to %q", e.sites.sites[siteShopID].Name)
}
})
t.Run("The existing site fields keep their names", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5, "default_robots": "noindex"}`)
got := decodeSiteBody(t, rec)
if rec.Code != http.StatusOK || got.DefaultSocialImageID == nil || *got.DefaultSocialImageID != 5 || got.DefaultRobots != "noindex" {
t.Errorf("status %d site %+v", rec.Code, got)
}
})
t.Run("Other roles and keys are refused", func(t *testing.T) {
const ownerText, sessionText = "Only the owner can do this.", "This action needs a real session."
cases := []struct {
name string
method string
path string
send func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult
text string
}{
{"PATCH with a verified admin", http.MethodPatch, "/v1/site/2", seoAsUser(apiKeyDevId), ownerText},
{"PATCH with a verified account with another role", http.MethodPatch, "/v1/site/2", seoAsUser(seoEditorID), ownerText},
{"PATCH with a valid API key", http.MethodPatch, "/v1/site/2", seoAsKeyOf(apiKeyDevId), sessionText},
{"POST with a verified admin", http.MethodPost, "/v1/site/", seoAsUser(apiKeyDevId), ownerText},
{"POST with a valid API key", http.MethodPost, "/v1/site/", seoAsKeyOf(apiKeyDevId), sessionText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.snapshot()
body := `{"site_name": "Hijack"}`
if c.method == http.MethodPost {
body = validSite(`, "site_name": "Hijack"`)
}
rec := c.send(e, t, c.method, c.path, body)
if rec.code != http.StatusForbidden || rec.text != c.text || !reflect.DeepEqual(e.sites.snapshot(), before) {
t.Errorf("status %d body %q", rec.code, rec.text)
}
})
}
})
t.Run("No credentials is unauthorised", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, 0, http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The role is checked before the body", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", "{not json")
expectStatusAndText(t, rec, http.StatusForbidden, "Only the owner can do this.")
})
t.Run("A no-op site edit records nothing", func(t *testing.T) {
e := newSeoEnv(t)
shop := e.sites.sites[siteShopID]
shop.SiteName, shop.DefaultDescription = "Renew", "A shop."
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew", "title_template": "%title% | %site%", "default_description": "A shop."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A site SEO edit is recorded", func(t *testing.T) {
cases := []struct{ body, field, from, to string }{
{`{"site_name": "Renew"}`, "site_name", "", "Renew"},
{`{"title_template": "%title% - %site%"}`, "title_template", "%title% | %site%", "%title% - %site%"},
{`{"default_description": "A shop."}`, "default_description", "", "A shop."},
}
for _, c := range cases {
t.Run(c.field, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionSiteEdit, audit.CategorySites, audit.OutcomeSuccess)
want := `[{"field":"` + c.field + `","kind":"text","before":"` + c.from + `","after":"` + c.to + `"}]`
if got := changesJSON(t, event); got != want {
t.Errorf("changes = %s, expected %s", got, want)
}
})
}
})
t.Run("The site defaults are the documented ones", func(t *testing.T) {
if site.DefaultTitleTemplate != "%title% | %site%" {
t.Errorf("template = %q", site.DefaultTitleTemplate)
}
})
}
type seoResult struct {
code int
text string
}
func seoAsUser(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
rec := e.as(t, userID, method, path, body)
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
}
}
func seoAsKeyOf(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
raw, _ := e.issueKey(userID, "Deploy bot")
rec := e.asKey(t, raw, method, path, body)
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
}
}
+158
View File
@@ -0,0 +1,158 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
seoEditorID = 5
seoSpringID = 8
seoOtherID = 9
seoCoverImage = 5
seoSocialImage = 6
seoSlugHeader = "X-Slug-Suggestion"
seoSlugTaken = "slug_taken"
seoTakenReason = "That slug is already used by another article on this site."
seoSpringTitle = "Spring release"
seoSpringSlug = "spring-release"
)
type seoEnv struct {
*producersEnv
sites *mockSiteRepo
authors *mockAuthorRepo
}
func newSeoEnv(t *testing.T) *seoEnv {
t.Helper()
p := newProducersEnv(t)
p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor")}
p.users.nextID = seoEditorID
authors := newMockAuthorRepo(p.articles)
sites := newMockSiteRepo(p.articles)
sites.seed(siteMainID, "Main", "main", true)
sites.seed(siteShopID, "Shop", "shop", false)
for id, seeded := range sites.sites {
seeded.TitleTemplate = site.DefaultTitleTemplate
sites.sites[id] = seeded
}
recorder := p.audit.recorder
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
c.MergeMediaUseCase = mergeMedia
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors)
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(c, p.settings))
p.router = router
p.seedBanner(seoCoverImage, "cover.png", "hash-cover")
p.seedBanner(seoSocialImage, "social.png", "hash-social")
return &seoEnv{producersEnv: p, sites: sites, authors: authors}
}
func (e *seoEnv) seedArticle(id uint32, title string, siteID uint32, slug string) article.Article {
seeded := article.Article{ID: id, Title: title, Body: "Body of " + title, SiteID: siteID, Slug: slug, SEODismissed: []string{}}
e.articles.articles[id] = seeded
if id > e.articles.nextID {
e.articles.nextID = id
}
return seeded
}
func (e *seoEnv) seedSpring() article.Article {
return e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, seoSpringSlug)
}
func (e *seoEnv) seedFullyOptimised(id uint32) {
stored := e.seedArticle(id, seoSpringTitle, siteMainID, "spring-full")
stored.Excerpt = "Short teaser."
stored.SEOTitle = "Spring release notes"
stored.SEODescription = "Everything new this spring."
stored.CoverImageID = idPointer(seoCoverImage)
stored.CoverImageAlt = "A field of tulips"
stored.SocialTitle = "Spring is here"
stored.SocialDescription = "Read the notes."
stored.SocialImageID = idPointer(seoSocialImage)
stored.CanonicalURL = "https://www.example.com/spring"
stored.Noindex = true
stored.SEODismissed = []string{"cover_alt", "short"}
e.articles.articles[id] = stored
}
func (e *seoEnv) patch(t *testing.T, id uint32, body string) *httptest.ResponseRecorder {
t.Helper()
return e.owner(t, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(id)), body)
}
func (e *seoEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.as(t, apiKeyOwnerId, method, path, body)
}
func (e *seoEnv) stored(id uint32) article.Article {
return e.articles.articles[id]
}
func decodeSeoObject(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return decoded
}
func expectStatusAndText(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, strings.TrimSpace(rec.Body.String()))
}
}
func expectStatusAndValidatorText(t *testing.T, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status || !strings.Contains(rec.Body.String(), "Field validation") {
t.Fatalf("expected %d with the validator text, got %d %q", status, rec.Code, rec.Body.String())
}
}
func repeated(character string, count int) string {
return strings.Repeat(character, count)
}
func jsonString(t *testing.T, value string) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
+1 -1
View File
@@ -103,7 +103,7 @@ func TestSitesCreating(t *testing.T) {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
created := decodeSiteBody(t, rec)
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA")}
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA"), TitleTemplate: site.DefaultTitleTemplate}
if created.ID == 0 || !reflect.DeepEqual(created, want) {
t.Errorf("created = %+v", created)
}
+1 -1
View File
@@ -116,7 +116,7 @@ func TestSiteUseCaseRules(t *testing.T) {
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
sites := newMockSiteRepo(repo)
sites.seed(1, "Main", "main", true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo())
missing := uint32(9)
_, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing})
+8 -4
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
@@ -121,7 +120,7 @@ func TestSitesMediaUsage(t *testing.T) {
}
})
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
t.Run("The usage lookup stays an array and later gains additive keys", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
e.useImageOnSite(siteShopID, 5)
@@ -129,8 +128,13 @@ func TestSitesMediaUsage(t *testing.T) {
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "site") {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil || rec.Code != http.StatusOK || len(items) != 2 {
t.Fatalf("status %d, body %s", rec.Code, rec.Body.String())
}
first := items[0]
if first["id"] != float64(1) || first["title"] != "Spring release" || first["published"] != true || first["type"] != "article" {
t.Errorf("the article item lost a key: %v", first)
}
})
}
+9 -1
View File
@@ -8,7 +8,9 @@ import (
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"gorm.io/gorm"
@@ -346,8 +348,14 @@ func mustCreateSite(t *testing.T, sites *persistence.SiteRepository, name string
return created
}
var persistedArticleCounter atomic.Uint32
func uniqueTestSlug() string {
return "article-" + strconv.Itoa(int(persistedArticleCounter.Add(1)))
}
func articleOn(siteID uint32) article.Article {
return article.Article{Title: "Article", Body: "Body", SiteID: siteID}
return article.Article{Title: "Article", Body: "Body", SiteID: siteID, Slug: uniqueTestSlug()}
}
func assertSingleDefault(t *testing.T, db *gorm.DB) {
+4 -2
View File
@@ -152,6 +152,7 @@ func (m *mockSiteRepo) Update(s site.Site) (site.Site, error) {
}
current.Name, current.BaseURL, current.DefaultRobots = s.Name, s.BaseURL, s.DefaultRobots
current.Locale, current.DefaultSocialImageID = s.Locale, s.DefaultSocialImageID
current.SiteName, current.TitleTemplate, current.DefaultDescription = s.SiteName, s.TitleTemplate, s.DefaultDescription
m.sites[s.ID] = current
return m.withCount(current), nil
}
@@ -277,11 +278,12 @@ func newSiteEnv(t *testing.T) *siteEnv {
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, plainAuthors())
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
+14 -4
View File
@@ -173,7 +173,10 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
delete(after.schema, name)
}
}
for _, name := range []string{"articles", "idx_articles_site_id"} {
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
}
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id"} {
delete(before.schema, name)
delete(after.schema, name)
}
@@ -181,21 +184,28 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
}
assertSiteColumnAdded(t, db)
delete(after.rows["articles"][0], "site_id")
if after.rows["articles"][0]["slug"] != "first" {
t.Errorf("the legacy article was not backfilled: %v", after.rows["articles"][0])
}
for _, column := range addedArticleColumns {
delete(after.rows["articles"][0], column)
}
if !reflect.DeepEqual(before.rows, after.rows) {
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
}
})
}
var addedArticleColumns = []string{"site_id", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper()
var columns []string
if err := db.Raw("SELECT name FROM pragma_table_info('articles') ORDER BY cid").Scan(&columns).Error; err != nil {
t.Fatalf("read article columns: %v", err)
}
if columns[len(columns)-1] != "site_id" {
t.Errorf("expected site_id to be the only added article column, got %v", columns)
if !reflect.DeepEqual(columns[len(columns)-len(addedArticleColumns):], addedArticleColumns) {
t.Errorf("expected the added article columns %v at the end, got %v", addedArticleColumns, columns)
}
for _, table := range []string{"sites", "authors", "article_authors"} {
var found int
+1 -1
View File
@@ -27,7 +27,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
+6 -6
View File
@@ -243,7 +243,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -255,7 +255,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
_, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -264,7 +264,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -276,7 +276,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
_, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -386,7 +386,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
err := uc.Delete(testCtx, 1)
if err != nil {
@@ -400,7 +400,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
err := uc.Delete(testCtx, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
+1 -1
View File
@@ -25,7 +25,7 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -217,7 +217,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)