package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" "fmt" "net/http" "reflect" "strconv" "strings" "testing" ) func TestSeoDataArticlePatch(t *testing.T) { t.Run("All SEO fields are set at once", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.authors.seed(5, "Camille", "camille") e.articles.credit(seoSpringID, 5) body := `{"slug": "spring-2026", "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, "seo_dismissed": ["cover_alt", "short"]}` rec := e.patch(t, seoSpringID, body) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } decoded := decodeSeoObject(t, rec) wantResponse := map[string]any{ "slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": []any{"cover_alt", "short"}, "title": seoSpringTitle, "body": "Body of Spring release", "site_id": float64(siteMainID), } for key, want := range wantResponse { if !reflect.DeepEqual(decoded[key], want) { t.Errorf("%s = %v, expected %v", key, decoded[key], want) } } if authors := decoded["authors"].([]any); len(authors) != 1 { t.Errorf("authors = %v", authors) } stored := e.stored(seoSpringID) if stored.Slug != "spring-2026" || stored.Excerpt != "Short teaser." || stored.SEOTitle != "Spring release notes" || stored.SEODescription != "Everything new this spring." || stored.CoverImageID == nil || *stored.CoverImageID != 5 || stored.CoverImageAlt != "A field of tulips" || stored.SocialTitle != "Spring is here" || stored.SocialDescription != "Read the notes." || stored.SocialImageID == nil || *stored.SocialImageID != 6 || stored.CanonicalURL != "https://www.example.com/spring" || !stored.Noindex || !reflect.DeepEqual(stored.SEODismissed, []string{"cover_alt", "short"}) { t.Errorf("stored = %+v", stored) } }) t.Run("Absent fields are untouched", func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, `{"seo_title": "New title"}`) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } want := before want.SEOTitle = "New title" if after := e.stored(seoSpringID); !reflect.DeepEqual(after, want) { t.Errorf("after = %+v, expected %+v", after, want) } }) t.Run("Text fields and the flag are cleared with empty values", func(t *testing.T) { cases := []struct { body string check func(a article.Article) bool }{ {`{"excerpt": ""}`, func(a article.Article) bool { return a.Excerpt == "" }}, {`{"seo_title": ""}`, func(a article.Article) bool { return a.SEOTitle == "" }}, {`{"seo_description": ""}`, func(a article.Article) bool { return a.SEODescription == "" }}, {`{"cover_image_alt": ""}`, func(a article.Article) bool { return a.CoverImageAlt == "" }}, {`{"social_title": ""}`, func(a article.Article) bool { return a.SocialTitle == "" }}, {`{"social_description": ""}`, func(a article.Article) bool { return a.SocialDescription == "" }}, {`{"canonical_url": ""}`, func(a article.Article) bool { return a.CanonicalURL == "" }}, {`{"noindex": false}`, func(a article.Article) bool { return !a.Noindex }}, {`{"seo_dismissed": []}`, func(a article.Article) bool { return len(a.SEODismissed) == 0 }}, {`{"cover_image_id": null}`, func(a article.Article) bool { return a.CoverImageID == nil }}, {`{"social_image_id": null}`, func(a article.Article) bool { return a.SocialImageID == nil }}, } for _, c := range cases { t.Run(c.body, func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) rec := e.patch(t, seoSpringID, c.body) if rec.Code != http.StatusOK || !c.check(e.stored(seoSpringID)) { t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID)) } }) } }) t.Run("Boundaries are accepted", func(t *testing.T) { cases := []struct{ name, body string }{ {"excerpt 300 characters", `{"excerpt": "` + repeated("a", 300) + `"}`}, {"excerpt 300 multi-byte characters", `{"excerpt": "` + repeated("é", 300) + `"}`}, {"seo_title 120 characters", `{"seo_title": "` + repeated("a", 120) + `"}`}, {"seo_description 320 characters", `{"seo_description": "` + repeated("a", 320) + `"}`}, {"cover_image_alt 200 characters", `{"cover_image_alt": "` + repeated("a", 200) + `"}`}, {"social_title 120 characters", `{"social_title": "` + repeated("a", 120) + `"}`}, {"social_description 320 characters", `{"social_description": "` + repeated("a", 320) + `"}`}, {"slug 100 characters", `{"slug": "` + repeated("a", 100) + `"}`}, {"canonical_url with query and fragment", `{"canonical_url": "https://example.com/a?b=1#c"}`}, {"canonical_url 2048 characters", `{"canonical_url": "https://example.com/` + repeated("a", 2048-len("https://example.com/")) + `"}`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.patch(t, seoSpringID, c.body) if rec.Code != http.StatusOK { t.Errorf("status %d: %.200s", rec.Code, rec.Body.String()) } }) } }) t.Run("Fields are validated", func(t *testing.T) { const validator = "validator" longURL := `{"canonical_url": "https://example.com/` + repeated("a", 2049-len("https://example.com/")) + `"}` cases := []struct { name string body string status int text string }{ {"excerpt 301", `{"excerpt": "` + repeated("a", 301) + `"}`, 400, validator}, {"seo_title 121", `{"seo_title": "` + repeated("a", 121) + `"}`, 400, validator}, {"seo_description 321", `{"seo_description": "` + repeated("a", 321) + `"}`, 400, validator}, {"cover_image_alt 201", `{"cover_image_alt": "` + repeated("a", 201) + `"}`, 400, validator}, {"social_title 121", `{"social_title": "` + repeated("a", 121) + `"}`, 400, validator}, {"social_description 321", `{"social_description": "` + repeated("a", 321) + `"}`, 400, validator}, {"canonical_url 2049", longURL, 400, validator}, {"slug uppercase", `{"slug": "Spring"}`, 400, "invalid_slug"}, {"slug underscore", `{"slug": "spring_2026"}`, 400, "invalid_slug"}, {"slug leading hyphen", `{"slug": "-spring"}`, 400, "invalid_slug"}, {"slug double hyphen", `{"slug": "spring--2026"}`, 400, "invalid_slug"}, {"slug 101", `{"slug": "` + repeated("a", 101) + `"}`, 400, "invalid_slug"}, {"canonical http", `{"canonical_url": "http://example.com/a"}`, 422, "invalid_url"}, {"canonical relative", `{"canonical_url": "/relative"}`, 422, "invalid_url"}, {"canonical bare host", `{"canonical_url": "example.com"}`, 422, "invalid_url"}, {"canonical no host", `{"canonical_url": "https://"}`, 422, "invalid_url"}, {"unknown dismissed code", `{"seo_dismissed": ["nope"]}`, 400, validator}, {"unknown cover image", `{"cover_image_id": 999}`, 400, "image_not_found"}, {"unknown social image", `{"social_image_id": 999}`, 400, "image_not_found"}, {"cover image zero", `{"cover_image_id": 0}`, 400, validator}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, c.body) if c.text == validator { expectStatusAndValidatorText(t, rec, c.status) } else { expectStatusAndText(t, rec, c.status, c.text) } if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) { t.Errorf("the article changed: %+v", after) } e.noEvent(t) }) } }) t.Run("A wrong type or null on other fields is refused", func(t *testing.T) { for _, body := range []string{`{}`, `{"colour": "x"}`, `{"slug": null}`, `{"excerpt": null}`, `{"noindex": null}`, `{"seo_dismissed": null}`, `{"noindex": "yes"}`, `{"excerpt": 5}`, `{"cover_image_id": "a"}`, `{"seo_dismissed": "x"}`} { t.Run(body, func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, body) expectStatusAndText(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage) if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) { t.Errorf("the article changed: %+v", after) } }) } }) t.Run("Duplicate dismissed codes are collapsed", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["short", "cover_alt", "short"]}`) if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, []string{"short", "cover_alt"}) { t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed) } }) t.Run("Every known dismissed code is accepted", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() codes := article.DismissCodes() rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["`+strings.Join(codes, `", "`)+`"]}`) if len(codes) != 11 || rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, codes) { t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed) } }) t.Run("A refused update changes nothing else", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, `{"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}`) expectStatusAndText(t, rec, http.StatusUnprocessableEntity, "invalid_url") if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) { t.Errorf("the article changed: %+v", after) } }) t.Run("SEO fields and the other fields change together", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.authors.seed(5, "Camille", "camille") rec := e.patch(t, seoSpringID, `{"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}`) stored := e.stored(seoSpringID) if rec.Code != http.StatusOK || stored.Body != "New" || stored.SiteID != 2 || stored.Excerpt != "Teaser" || !reflect.DeepEqual(e.articles.credits[seoSpringID], []uint32{5}) { t.Errorf("status %d, stored %+v, credits %v", rec.Code, stored, e.articles.credits[seoSpringID]) } }) t.Run("An update without SEO fields keeps them", func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, `{"body": "New"}`) want := before want.Body = "New" if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID), want) { t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID)) } }) t.Run("Any account that can edit the article edits its SEO fields", func(t *testing.T) { callers := []struct { name string send func(e *seoEnv, t *testing.T) int }{ {"the owner's jwt cookie", func(e *seoEnv, t *testing.T) int { return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code }}, {"a verified admin's jwt cookie", func(e *seoEnv, t *testing.T) int { return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code }}, {"a verified account with another role", func(e *seoEnv, t *testing.T) int { return e.as(t, seoEditorID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code }}, {"a valid API key", func(e *seoEnv, t *testing.T) int { raw, _ := e.issueKey(apiKeyDevId, "Deploy bot") return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code }}, } for _, c := range callers { t.Run(c.name, func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() status := c.send(e, t) if status != http.StatusOK || e.stored(seoSpringID).Excerpt != "Teaser" { t.Errorf("status %d, excerpt %q", status, e.stored(seoSpringID).Excerpt) } }) } }) t.Run("Without a verified account the article is not edited", func(t *testing.T) { callers := []struct { name string userID uint32 status int }{ {"an unverified account's jwt cookie", apiKeyQuinnId, http.StatusForbidden}, {"no credentials", 0, http.StatusUnauthorized}, } for _, c := range callers { t.Run(c.name, func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() before := e.stored(seoSpringID) rec := e.as(t, c.userID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`) if rec.Code != c.status || !reflect.DeepEqual(e.stored(seoSpringID), before) { t.Errorf("status %d", rec.Code) } e.noEvent(t) }) } }) t.Run("A missing article is not found", func(t *testing.T) { e := newSeoEnv(t) rec := e.patch(t, 999, `{"excerpt": "x"}`) expectStatusAndText(t, rec, http.StatusNotFound, "Article not found") e.noEvent(t) }) t.Run("A malformed id is refused", func(t *testing.T) { e := newSeoEnv(t) rec := e.owner(t, http.MethodPatch, "/v1/article/abc", `{"excerpt": "x"}`) expectStatusAndText(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage) e.noEvent(t) }) t.Run("noindex is only a flag", func(t *testing.T) { e := newSeoEnv(t) stored := e.seedSpring() stored.IsOnline = true e.articles.articles[seoSpringID] = stored rec := e.patch(t, seoSpringID, `{"noindex": true}`) anonymous := e.as(t, 0, http.MethodGet, "/v1/article/8", "") if rec.Code != http.StatusOK || !e.stored(seoSpringID).IsOnline { t.Fatalf("status %d, online %v", rec.Code, e.stored(seoSpringID).IsOnline) } if anonymous.Code != http.StatusOK || decodeSeoObject(t, anonymous)["noindex"] != true { t.Errorf("anonymous read: %d %s", anonymous.Code, anonymous.Body.String()) } }) t.Run("The cover alt is independent of the cover", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.patch(t, seoSpringID, `{"cover_image_alt": "A field"}`) decoded := decodeSeoObject(t, rec) if rec.Code != http.StatusOK || decoded["cover_image_id"] != nil || decoded["cover_image_alt"] != "A field" { t.Errorf("status %d body %s", rec.Code, rec.Body.String()) } }) } func TestSeoDataArticleReads(t *testing.T) { allFields := []string{"slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"} otherTenFields := []string{"seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"} t.Run("Single-article reads carry all twelve fields", func(t *testing.T) { requests := []struct{ method, path string }{ {http.MethodGet, "/v1/article/8"}, {http.MethodPatch, "/v1/article/8"}, {http.MethodPatch, "/v1/article/8/publish"}, {http.MethodPatch, "/v1/article/8/unpublish"}, } for _, request := range requests { t.Run(request.method+" "+request.path, func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) body := "" if request.method == http.MethodPatch && !strings.Contains(request.path, "publish") { body = `{"body": "same"}` } rec := e.owner(t, request.method, request.path, body) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } decoded := decodeSeoObject(t, rec) stored := e.stored(seoSpringID) want := map[string]any{ "slug": stored.Slug, "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": []any{"cover_alt", "short"}, } for _, key := range allFields { if !reflect.DeepEqual(decoded[key], want[key]) { t.Errorf("%s = %v, expected %v", key, decoded[key], want[key]) } } }) } }) t.Run("A new article has the defaults", func(t *testing.T) { e := newSeoEnv(t) rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`) if rec.Code != http.StatusCreated { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } decoded := decodeSeoObject(t, rec) want := map[string]any{"slug": "spring-release", "excerpt": "", "seo_title": "", "seo_description": "", "cover_image_id": nil, "cover_image_alt": "", "social_title": "", "social_description": "", "social_image_id": nil, "canonical_url": "", "noindex": false, "seo_dismissed": []any{}} for key, value := range want { if !reflect.DeepEqual(decoded[key], value) { t.Errorf("%s = %#v, expected %#v", key, decoded[key], value) } } }) t.Run("The list carries the excerpt and the slug", func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoSpringID) stored := e.stored(seoSpringID) stored.Slug = "spring-release" e.articles.articles[seoSpringID] = stored e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes") rec := e.owner(t, http.MethodGet, "/v1/article/", "") if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } decoded := decodeSeoObject(t, rec) items := decoded["items"].([]any) if len(items) != 2 || decoded["total"] != float64(2) || decoded["page"] != float64(1) || decoded["size"] != float64(25) || decoded["counts"] == nil { t.Fatalf("list = %v", decoded) } for _, raw := range items { item := raw.(map[string]any) if _, ok := item["excerpt"]; !ok { t.Errorf("item without excerpt: %v", item) } if _, ok := item["slug"]; !ok { t.Errorf("item without slug: %v", item) } for _, key := range otherTenFields { if _, present := item[key]; present { t.Errorf("item carries %s: %v", key, item) } } if item["id"] == float64(seoSpringID) && (item["excerpt"] != "Short teaser." || item["slug"] != "spring-release") { t.Errorf("article 8 item = %v", item) } } }) t.Run("Anonymous readers see the SEO fields of online articles", func(t *testing.T) { e := newSeoEnv(t) e.seedFullyOptimised(seoOtherID) online := e.stored(seoOtherID) online.IsOnline = true e.articles.articles[seoOtherID] = online rec := e.as(t, 0, http.MethodGet, "/v1/article/9", "") decoded := decodeSeoObject(t, rec) if rec.Code != http.StatusOK || decoded["excerpt"] != "Short teaser." || decoded["canonical_url"] != "https://www.example.com/spring" { t.Errorf("status %d body %s", rec.Code, rec.Body.String()) } }) t.Run("An offline article is still not found publicly", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.as(t, 0, http.MethodGet, "/v1/article/8", "") unverified := e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/8", "") expectStatusAndText(t, rec, http.StatusNotFound, "Article not found") expectStatusAndText(t, unverified, http.StatusNotFound, "Article not found") }) t.Run("The list for anonymous callers shows online articles only", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() online := e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes") online.IsOnline = true online.Excerpt = "Leaves." e.articles.articles[seoOtherID] = online rec := e.as(t, 0, http.MethodGet, "/v1/article/", "") items := decodeSeoObject(t, rec)["items"].([]any) if rec.Code != http.StatusOK || len(items) != 1 { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } item := items[0].(map[string]any) if item["excerpt"] != "Leaves." || item["slug"] != "autumn-notes" { t.Errorf("item = %v", item) } }) } func TestSeoDataSlugGeneration(t *testing.T) { t.Run("The slug is generated from the title at creation", func(t *testing.T) { for _, c := range []struct{ title, slug string }{ {"Spring release", "spring-release"}, {"Été à Paris", "ete-a-paris"}, {"Dr. O'Neil Smith", "dr-o-neil-smith"}, {"!!!", "article"}, } { t.Run(c.title, func(t *testing.T) { e := newSeoEnv(t) rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, c.title)+`, "body": "x"}`) if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != c.slug { t.Errorf("status %d body %s", rec.Code, rec.Body.String()) } }) } }) t.Run("A long title gives a slug of at most 100 characters", func(t *testing.T) { e := newSeoEnv(t) title := strings.TrimSpace(repeated("longword ", 22)) rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`) slug := decodeSeoObject(t, rec)["slug"].(string) if rec.Code != http.StatusCreated || len(slug) > 100 || strings.HasSuffix(slug, "-") || !article.IsValidSlug(slug) { t.Errorf("status %d slug %q (%d)", rec.Code, slug, len(slug)) } }) t.Run("A generated slug that is taken on the site is de-duplicated silently", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(1, "One", siteMainID, "spring-release") e.seedArticle(2, "Two", siteMainID, "spring-release-2") rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`) if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != "spring-release-3" { t.Errorf("status %d body %s", rec.Code, rec.Body.String()) } }) t.Run("A title without letters or digits is de-duplicated too", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(1, "One", siteMainID, "article") rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "!!!", "body": "x"}`) if decodeSeoObject(t, rec)["slug"] != "article-2" { t.Errorf("body %s", rec.Body.String()) } }) t.Run("The suffix still fits the slug limit at creation", func(t *testing.T) { e := newSeoEnv(t) title := repeated("a", 100) e.seedArticle(1, "One", siteMainID, title) rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`) slug := decodeSeoObject(t, rec)["slug"].(string) if rec.Code != http.StatusCreated || len(slug) != 100 || !strings.HasSuffix(slug, "-2") { t.Errorf("status %d slug %q", rec.Code, slug) } }) t.Run("The same title on another site keeps the plain slug", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(1, "One", siteMainID, "spring-release") e.sites.seed(siteShopID, "Shop", "shop", true) defaultSite := e.sites.sites[siteMainID] defaultSite.IsDefault = false e.sites.sites[siteMainID] = defaultSite rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`) decoded := decodeSeoObject(t, rec) if decoded["slug"] != "spring-release" || decoded["site_id"] != float64(siteShopID) { t.Errorf("body %s", rec.Body.String()) } }) t.Run("A new article is created on the default site, so uniqueness uses that site", func(t *testing.T) { e := newSeoEnv(t) e.sites.seed(siteMainID, "Main", "main", false) e.sites.seed(siteShopID, "Shop", "shop", true) e.seedArticle(1, "One", siteShopID, "spring-release") rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`) if decodeSeoObject(t, rec)["slug"] != "spring-release-2" { t.Errorf("body %s", rec.Body.String()) } }) } func TestSeoDataSlugEditing(t *testing.T) { assertConflict := func(t *testing.T, rec interface { Result() *http.Response }, status int, suggestion string) { t.Helper() response := rec.Result() if response.StatusCode != status || response.Header.Get(seoSlugHeader) != suggestion { t.Errorf("status %d suggestion %q, expected %d %q", response.StatusCode, response.Header.Get(seoSlugHeader), status, suggestion) } } t.Run("The same slug is allowed on two sites", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, "Spring", siteMainID, "spring") e.seedArticle(seoOtherID, "Other", siteShopID, "other") rec := e.patch(t, seoOtherID, `{"slug": "spring"}`) if rec.Code != http.StatusOK || e.stored(seoOtherID).Slug != "spring" { t.Errorf("status %d", rec.Code) } }) t.Run("The slug is editable at any time", func(t *testing.T) { e := newSeoEnv(t) stored := e.seedSpring() stored.IsOnline = true e.articles.articles[seoSpringID] = stored rec := e.patch(t, seoSpringID, `{"slug": "spring-2026"}`) if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring-2026" || !e.stored(seoSpringID).IsOnline { t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID)) } }) t.Run("Keeping the own slug is not a collision", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.patch(t, seoSpringID, `{"slug": "spring-release", "excerpt": "x"}`) if rec.Code != http.StatusOK || e.stored(seoSpringID).Excerpt != "x" { t.Errorf("status %d", rec.Code) } }) t.Run("A taken slug is refused with a suggestion", func(t *testing.T) { for _, c := range []struct { taken []string suggestion string }{ {[]string{"spring"}, "spring-2"}, {[]string{"spring", "spring-2"}, "spring-3"}, {[]string{"spring", "spring-2", "spring-3"}, "spring-4"}, } { t.Run(strings.Join(c.taken, ","), func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() for index, slug := range c.taken { e.seedArticle(uint32(20+index), "Taken "+slug, siteMainID, slug) } before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, `{"slug": "spring"}`) expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken) assertConflict(t, rec, http.StatusConflict, c.suggestion) if !reflect.DeepEqual(e.stored(seoSpringID), before) { t.Errorf("the article changed") } }) } }) t.Run("The suggestion fits the slug limit", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() long := repeated("a", 100) e.seedArticle(20, "Long", siteMainID, long) rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`) suggestion := rec.Result().Header.Get(seoSlugHeader) if rec.Code != http.StatusConflict || len(suggestion) != 100 || !strings.HasSuffix(suggestion, "-2") { t.Errorf("status %d suggestion %q", rec.Code, suggestion) } }) t.Run("The suggestion skips every taken suffix and still fits 100 characters", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() long := repeated("a", 100) e.seedArticle(20, "Long", siteMainID, long) e.seedArticle(21, "Long 2", siteMainID, long[:98]+"-2") e.seedArticle(22, "Long 3", siteMainID, long[:98]+"-3") rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`) suggestion := rec.Result().Header.Get(seoSlugHeader) if rec.Code != http.StatusConflict || suggestion != long[:98]+"-4" || len(suggestion) != 100 { t.Errorf("status %d suggestion %q", rec.Code, suggestion) } }) t.Run("The suggestion ignores the article itself", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, "Spring", siteMainID, "spring-2") e.seedArticle(seoOtherID, "Other", siteMainID, "spring") rec := e.patch(t, seoSpringID, `{"slug": "spring"}`) assertConflict(t, rec, http.StatusConflict, "spring-2") }) t.Run("The suggestion header is only set on the slug conflict", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.seedArticle(20, "Taken", siteMainID, "taken") requests := []struct{ method, path, body string }{ {http.MethodPatch, "/v1/article/8", `{"slug": "fresh"}`}, {http.MethodPatch, "/v1/article/8", `{"slug": "Bad Slug"}`}, {http.MethodPatch, "/v1/article/999", `{"slug": "fresh"}`}, {http.MethodGet, "/v1/article/8", ""}, {http.MethodGet, "/v1/article/", ""}, {http.MethodPost, "/v1/article", `{"title": "Taken", "body": "x"}`}, } for _, request := range requests { rec := e.owner(t, request.method, request.path, request.body) if header := rec.Result().Header.Get(seoSlugHeader); header != "" { t.Errorf("%s %s answered %d with %s: %q", request.method, request.path, rec.Code, seoSlugHeader, header) } } }) t.Run("The repository can report a race", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.articles.reportSlugRace = true before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, `{"slug": "fresh-looking"}`) expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken) assertConflict(t, rec, http.StatusConflict, "fresh-looking-2") if !reflect.DeepEqual(e.stored(seoSpringID), before) { t.Errorf("the article changed") } }) t.Run("An empty slug regenerates it from the title", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom-address") rec := e.patch(t, seoSpringID, `{"slug": ""}`) if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug { t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug) } }) t.Run("Regeneration follows the creation rule", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, "!!!", siteMainID, "custom") rec := e.patch(t, seoSpringID, `{"slug": ""}`) if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "article" { t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug) } }) t.Run("Regenerating to the slug it already has is a no-op", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.patch(t, seoSpringID, `{"slug": ""}`) if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug { t.Errorf("status %d", rec.Code) } e.noEvent(t) }) t.Run("Regeneration that collides is refused, never silently suffixed", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoOtherID, "Other", siteMainID, seoSpringSlug) e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom") rec := e.patch(t, seoSpringID, `{"slug": ""}`) expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken) assertConflict(t, rec, http.StatusConflict, "spring-release-2") if e.stored(seoSpringID).Slug != "custom" { t.Errorf("slug %q", e.stored(seoSpringID).Slug) } }) t.Run("Regeneration combined with a move uses the target site", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug) rec := e.patch(t, seoSpringID, `{"site_id": 2, "slug": ""}`) assertConflict(t, rec, http.StatusConflict, "spring-release-2") }) t.Run("Moving to another site re-checks the slug on the target", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, "Spring", siteMainID, "spring") e.seedArticle(seoOtherID, "Other", siteShopID, "spring") rec := e.patch(t, seoSpringID, `{"site_id": 2}`) expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken) assertConflict(t, rec, http.StatusConflict, "spring-2") if stored := e.stored(seoSpringID); stored.SiteID != siteMainID || stored.Slug != "spring" { t.Errorf("stored %+v", stored) } }) t.Run("Moving to a site where the slug is free keeps the slug", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() rec := e.patch(t, seoSpringID, `{"site_id": 2}`) if stored := e.stored(seoSpringID); rec.Code != http.StatusOK || stored.SiteID != 2 || stored.Slug != seoSpringSlug { t.Errorf("status %d stored %+v", rec.Code, stored) } }) t.Run("Moving and changing the slug together checks the new slug on the target", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.seedArticle(seoOtherID, "Other", siteMainID, "elsewhere") e.seedArticle(30, "On shop", siteShopID, "spring") moved := e.patch(t, seoSpringID, `{"site_id": 2, "slug": "autumn"}`) refused := e.patch(t, seoOtherID, `{"site_id": 2, "slug": "spring"}`) if moved.Code != http.StatusOK || e.stored(seoSpringID).Slug != "autumn" { t.Errorf("move status %d", moved.Code) } assertConflict(t, refused, http.StatusConflict, "spring-2") }) t.Run("A refused move changes no SEO field", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug) before := e.stored(seoSpringID) rec := e.patch(t, seoSpringID, `{"site_id": 2, "excerpt": "new"}`) if rec.Code != http.StatusConflict || !reflect.DeepEqual(e.stored(seoSpringID), before) { t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID)) } }) t.Run("A freed slug can be reused", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, "Spring", siteMainID, "autumn") e.seedArticle(seoOtherID, "Other", siteMainID, "spring") deleted := e.owner(t, http.MethodDelete, "/v1/article/9", "") rec := e.patch(t, seoSpringID, `{"slug": "spring"}`) if deleted.Code != http.StatusNoContent || rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring" { t.Errorf("delete %d, patch %d", deleted.Code, rec.Code) } }) t.Run("Publishing and unpublishing never change the slug", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() published := e.owner(t, http.MethodPatch, "/v1/article/8/publish", "") unpublished := e.owner(t, http.MethodPatch, "/v1/article/8/unpublish", "") if published.Code != http.StatusOK || unpublished.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug { t.Errorf("publish %d unpublish %d slug %q", published.Code, unpublished.Code, e.stored(seoSpringID).Slug) } }) } func TestSeoDataSlugSuggestionUseCase(t *testing.T) { t.Run("The suggestion is the first free suffix", func(t *testing.T) { for taken := 0; taken < 6; taken++ { e := newSeoEnv(t) e.seedSpring() e.seedArticle(20, "Base", siteMainID, "spring") for attempt := 2; attempt < 2+taken; attempt++ { e.seedArticle(uint32(20+attempt), "Suffix "+strconv.Itoa(attempt), siteMainID, "spring-"+strconv.Itoa(attempt)) } rec := e.patch(t, seoSpringID, `{"slug": "spring"}`) want := "spring-" + strconv.Itoa(2+taken) if got := rec.Result().Header.Get(seoSlugHeader); got != want { t.Errorf("with %d suffixes taken: %q, expected %q", taken, got, want) } } }) t.Run("The suggestion is computed on the target site of a move", func(t *testing.T) { e := newSeoEnv(t) e.seedArticle(seoSpringID, "Spring", siteMainID, "spring") e.seedArticle(20, "Main also", siteMainID, "spring-2") e.seedArticle(21, "Shop", siteShopID, "spring") rec := e.patch(t, seoSpringID, `{"site_id": 2}`) if got := rec.Result().Header.Get(seoSlugHeader); got != "spring-2" { t.Errorf("suggestion %q, expected the slug that is free on the shop", got) } }) t.Run("A suggestion lookup failure is an internal error without header", func(t *testing.T) { e := newSeoEnv(t) e.seedSpring() e.seedArticle(20, "Taken", siteMainID, "taken") e.articles.fail.failNext("SlugTaken") rec := e.patch(t, seoSpringID, `{"slug": "fresh"}`) if rec.Code != http.StatusInternalServerError || rec.Result().Header.Get(seoSlugHeader) != "" { t.Errorf("status %d", rec.Code) } }) } func TestSeoDataDismissCodes(t *testing.T) { t.Run("The dismissed codes are the eleven suggestion codes", func(t *testing.T) { if got := fmt.Sprint(article.DismissCodes()); got != "[title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short]" { t.Errorf("codes = %s", got) } }) }