269 lines
9.5 KiB
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, "")
|
|
}
|