Files
RenewCMS/test/seo_output_support_test.go

272 lines
7.9 KiB
Go

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 = "<p>We price by the square metre. Tiles cost extra.</p>"
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 "<p>" + strings.Repeat("a", runes) + "</p>" + 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 })
}