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