feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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{}}
|
||||
}
|
||||
@@ -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())}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user