package test import ( "RenewCMS/api" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "io" "log" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) const ( seoEditorID = 5 seoSpringID = 8 seoOtherID = 9 seoCoverImage = 5 seoSocialImage = 6 seoSlugHeader = "X-Slug-Suggestion" seoSlugTaken = "slug_taken" seoTakenReason = "That slug is already used by another article on this site." seoSpringTitle = "Spring release" seoSpringSlug = "spring-release" ) type seoEnv struct { *producersEnv sites *mockSiteRepo authors *mockAuthorRepo } func newSeoEnv(t *testing.T) *seoEnv { t.Helper() p := newProducersEnv(t) p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor")} p.users.nextID = seoEditorID authors := newMockAuthorRepo(p.articles) sites := newMockSiteRepo(p.articles) sites.seed(siteMainID, "Main", "main", true) sites.seed(siteShopID, "Shop", "shop", false) for id, seeded := range sites.sites { seeded.TitleTemplate = site.DefaultTitleTemplate sites.sites[id] = seeded } recorder := p.audit.recorder renderer := &fakeBodyRenderer{} mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors) c := p.container c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles) c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors) c.MergeMediaUseCase = mergeMedia c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors) c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors) c.ListSitesUseCase = useCases.NewListSitesUseCase(sites) c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder) c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder) c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder) c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder) c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors) router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(c, p.settings)) p.router = router p.seedBanner(seoCoverImage, "cover.png", "hash-cover") p.seedBanner(seoSocialImage, "social.png", "hash-social") return &seoEnv{producersEnv: p, sites: sites, authors: authors} } func (e *seoEnv) seedArticle(id uint32, title string, siteID uint32, slug string) article.Article { seeded := article.Article{ID: id, Title: title, Body: "Body of " + title, SiteID: siteID, Slug: slug, SEODismissed: []string{}} e.articles.articles[id] = seeded if id > e.articles.nextID { e.articles.nextID = id } return seeded } func (e *seoEnv) seedSpring() article.Article { return e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, seoSpringSlug) } func (e *seoEnv) seedFullyOptimised(id uint32) { stored := e.seedArticle(id, seoSpringTitle, siteMainID, "spring-full") stored.Excerpt = "Short teaser." stored.SEOTitle = "Spring release notes" stored.SEODescription = "Everything new this spring." stored.CoverImageID = idPointer(seoCoverImage) stored.CoverImageAlt = "A field of tulips" stored.SocialTitle = "Spring is here" stored.SocialDescription = "Read the notes." stored.SocialImageID = idPointer(seoSocialImage) stored.CanonicalURL = "https://www.example.com/spring" stored.Noindex = true stored.SEODismissed = []string{"cover_alt", "short"} e.articles.articles[id] = stored } func (e *seoEnv) patch(t *testing.T, id uint32, body string) *httptest.ResponseRecorder { t.Helper() return e.owner(t, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(id)), body) } func (e *seoEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() return e.as(t, apiKeyOwnerId, method, path, body) } func (e *seoEnv) stored(id uint32) article.Article { return e.articles.articles[id] } func decodeSeoObject(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() var decoded map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return decoded } func expectStatusAndText(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body { t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, strings.TrimSpace(rec.Body.String())) } } func expectStatusAndValidatorText(t *testing.T, rec *httptest.ResponseRecorder, status int) { t.Helper() if rec.Code != status || !strings.Contains(rec.Body.String(), "Field validation") { t.Fatalf("expected %d with the validator text, got %d %q", status, rec.Code, rec.Body.String()) } } func repeated(character string, count int) string { return strings.Repeat(character, count) } func jsonString(t *testing.T, value string) string { t.Helper() encoded, err := json.Marshal(value) if err != nil { t.Fatal(err) } return string(encoded) }