Files
RenewCMS/test/seo_data_cleaner_test.go

354 lines
13 KiB
Go

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)
}
})
}