Files
RenewCMS/test/seo_data_activity_http_test.go

269 lines
9.5 KiB
Go

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