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