feat: SEO backend output (2/4)
This commit is contained in:
1 parent
0eb46b0d74
commit
eb9876fde3
32 files changed
+3859
-65
No files matched your search
@@ -0,0 +1,271 @@
|
||||
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 })
|
||||
}
|
||||
Reference in new issue
Block a user