package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/site" "RenewCMS/internal/infrastructure/useCases" "net/http" "net/http/httptest" "sort" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" "RenewCMS/api" ) const ( seoOutArticleID = 12 seoOutDraftID = 13 seoOutArticleTitle = "How we price a bathroom renovation" seoOutArticleSlug = "how-we-price-a-bathroom-renovation" seoOutBody = "
We price by the square metre. Tiles cost extra.
" seoOutBase = "https://www.renew.example" seoOutSiteImage = 7 seoOutJournalID = 2 seoOutAuthorMarta = 2 seoOutAuthorZoe = 3 ) var ( seoOutCreated = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) seoOutModified = time.Date(2026, 9, 10, 9, 30, 0, 0, time.UTC) ) type mockSeoOutArticleRepo struct { *mockArticleRepo reads map[string]int writes int } func newMockSeoOutArticleRepo(inner *mockArticleRepo) *mockSeoOutArticleRepo { return &mockSeoOutArticleRepo{mockArticleRepo: inner, reads: map[string]int{}} } func (m *mockSeoOutArticleRepo) readCalls() int { total := 0 for _, count := range m.reads { total += count } return total } func (m *mockSeoOutArticleRepo) Get(id uint32) (article.Article, error) { m.reads["Get"]++ return m.mockArticleRepo.Get(id) } func (m *mockSeoOutArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) { m.reads["FindPage"]++ return m.mockArticleRepo.FindPage(filters) } func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) { m.reads["CountByVisibility"]++ return m.mockArticleRepo.CountByVisibility(onlineOnly) } func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) { m.reads["SlugTaken"]++ return m.mockArticleRepo.SlugTaken(siteID, slug, exceptID) } func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) { m.writes++ return m.mockArticleRepo.Create(a) } func (m *mockSeoOutArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) { m.writes++ return m.mockArticleRepo.UpdateBody(id, body) } func (m *mockSeoOutArticleRepo) Update(id uint32, update article.Update) (article.Article, error) { m.writes++ return m.mockArticleRepo.Update(id, update) } func (m *mockSeoOutArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) { m.writes++ return m.mockArticleRepo.UpdateIsOnline(id, isOnline) } func (m *mockSeoOutArticleRepo) Delete(id uint32) error { m.writes++ return m.mockArticleRepo.Delete(id) } var _ article.Repository = &mockSeoOutArticleRepo{} type seoOutEnv struct { *seoEnv counted *mockSeoOutArticleRepo } func newSeoOutEnv(t *testing.T) *seoOutEnv { t.Helper() e := newSeoEnv(t) counted := newMockSeoOutArticleRepo(e.articles) locale := "en" renew := e.sites.sites[siteMainID] renew.Key = "renew" renew.BaseURL = seoOutBase renew.SiteName = "Renew" renew.TitleTemplate = "%title% | %site%" renew.DefaultDescription = "Renovation notes from the Renew team." renew.DefaultRobots = "index" renew.Locale = &locale renew.DefaultSocialImageID = idPointer(seoOutSiteImage) e.sites.sites[siteMainID] = renew journal := e.sites.sites[seoOutJournalID] journal.Key = "journal" journal.BaseURL = "https://journal.renew.example" journal.TitleTemplate = "%title%" journal.DefaultRobots = "noindex" e.sites.sites[seoOutJournalID] = journal e.seedBanner(seoOutSiteImage, "site.png", "hash-site") c := e.container c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images) c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images) c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites) router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(c, e.settings)) e.router = router e.articles.articles[seoOutArticleID] = article.Article{ ID: seoOutArticleID, Title: seoOutArticleTitle, Body: seoOutBody, IsOnline: true, SiteID: siteMainID, Slug: seoOutArticleSlug, SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified, } e.articles.articles[seoOutDraftID] = article.Article{ ID: seoOutDraftID, Title: "A draft about tiles", Body: seoOutBody, SiteID: siteMainID, Slug: "a-draft-about-tiles", SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified, } e.articles.nextID = 100 return &seoOutEnv{seoEnv: e, counted: counted} } func (e *seoOutEnv) change(id uint32, mutate func(*article.Article)) { found := e.articles.articles[id] mutate(&found) e.articles.articles[id] = found } func (e *seoOutEnv) changeSite(id uint32, mutate func(*site.Site)) { found := e.sites.sites[id] mutate(&found) e.sites.sites[id] = found } func (e *seoOutEnv) credit(id uint32, name string, website string, links ...author.SocialLink) { found := e.authors.seed(id, name, strings.ToLower(strings.ReplaceAll(name, " ", "-"))) if website != "" { found.Website = &website } found.SocialLinks = append([]author.SocialLink{}, links...) e.authors.authors[id] = found e.articles.credits[seoOutArticleID] = append(e.articles.credits[seoOutArticleID], id) } func (e *seoOutEnv) call(t *testing.T, who string, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } switch who { case "owner": req.AddCookie(e.cookie(t, apiKeyOwnerId, 0)) case "admin": req.AddCookie(e.cookie(t, apiKeyPatId, 0)) case "editor": req.AddCookie(e.cookie(t, seoEditorID, 0)) case "unverified": req.AddCookie(e.cookie(t, apiKeyQuinnId, 0)) case "malformed": req.AddCookie(&http.Cookie{Name: "jwt", Value: "not-a-token"}) case "key": raw, _ := e.issueKey(apiKeyDevId, "seo") req.Header.Set("Authorization", "Bearer "+raw) } rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) return rec } func (e *seoOutEnv) resolve(t *testing.T, id int) map[string]any { t.Helper() rec := e.call(t, "", http.MethodGet, "/v1/article/"+strconv.Itoa(id)+"/seo", "") if rec.Code != http.StatusOK { t.Fatalf("resolve %d: %d %s", id, rec.Code, rec.Body.String()) } return decodeSeoObject(t, rec) } func (e *seoOutEnv) check(t *testing.T, body string) map[string]any { t.Helper() rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", body) if rec.Code != http.StatusOK { t.Fatalf("check %s: %d %s", body, rec.Code, rec.Body.String()) } return decodeSeoObject(t, rec) } func (e *seoOutEnv) checkCodes(t *testing.T, body string) []string { t.Helper() return suggestionCodes(e.check(t, body)) } func suggestionCodes(checked map[string]any) []string { codes := []string{} for _, item := range checked["suggestions"].([]any) { codes = append(codes, item.(map[string]any)["code"].(string)) } return codes } func hasCode(codes []string, code string) bool { for _, found := range codes { if found == code { return true } } return false } func sortedKeys(object map[string]any) string { keys := make([]string, 0, len(object)) for key := range object { keys = append(keys, key) } sort.Strings(keys) return strings.Join(keys, ",") } func nested(object map[string]any, key string) map[string]any { return object[key].(map[string]any) } func textBody(runes int, extra string) string { return "" + strings.Repeat("a", runes) + "
" + extra } func jsonBody(t *testing.T, field string, value string) string { t.Helper() return `{"` + field + `": ` + jsonString(t, value) + `}` } func listIDs(list map[string]any) []int { ids := []int{} for _, item := range list["items"].([]any) { ids = append(ids, int(item.(map[string]any)["id"].(float64))) } return ids } func (e *seoOutEnv) changeSiteTemplate(template string) { e.changeSite(siteMainID, func(s *site.Site) { s.TitleTemplate = template }) }