272 lines
7.9 KiB
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 })
|
|
}
|