package test import ( "RenewCMS/api" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/site" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "io" "log" "net/http/httptest" "sort" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) type mockSiteRepo struct { sites map[uint32]site.Site articles *mockArticleRepo nextID uint32 fail failSwitch createCalls int deleteCalls int deleteReassignments []*uint32 setDefaultCalls int } func newMockSiteRepo(articles *mockArticleRepo) *mockSiteRepo { return &mockSiteRepo{sites: map[uint32]site.Site{}, articles: articles} } func (m *mockSiteRepo) seed(id uint32, name string, key string, isDefault bool) site.Site { seeded := site.Site{ID: id, Name: name, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: site.RobotsIndex, IsDefault: isDefault} m.sites[id] = seeded if id > m.nextID { m.nextID = id } return seeded } func (m *mockSiteRepo) withCount(s site.Site) site.Site { s.ArticleCount = 0 for _, a := range m.articles.articles { if a.SiteID == s.ID { s.ArticleCount++ } } return s } func (m *mockSiteRepo) snapshot() map[uint32]site.Site { copied := make(map[uint32]site.Site, len(m.sites)) for id, s := range m.sites { copied[id] = s } return copied } func (m *mockSiteRepo) defaults() []uint32 { ids := []uint32{} for id, s := range m.sites { if s.IsDefault { ids = append(ids, id) } } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return ids } func (m *mockSiteRepo) List() ([]site.Site, error) { if err := m.fail.trigger("List"); err != nil { return nil, err } result := make([]site.Site, 0, len(m.sites)) for _, s := range m.sites { result = append(result, m.withCount(s)) } sort.Slice(result, func(i, j int) bool { if result[i].IsDefault != result[j].IsDefault { return result[i].IsDefault } left, right := strings.ToLower(result[i].Name), strings.ToLower(result[j].Name) if left != right { return left < right } return result[i].ID < result[j].ID }) return result, nil } func (m *mockSiteRepo) Get(id uint32) (site.Site, error) { if err := m.fail.trigger("Get"); err != nil { return site.Site{}, err } found, ok := m.sites[id] if !ok { return site.Site{}, site.ErrSiteNotFound } return m.withCount(found), nil } func (m *mockSiteRepo) GetByKey(key string) (site.Site, error) { if err := m.fail.trigger("GetByKey"); err != nil { return site.Site{}, err } for _, s := range m.sites { if s.Key == key { return m.withCount(s), nil } } return site.Site{}, site.ErrSiteNotFound } func (m *mockSiteRepo) GetDefault() (site.Site, error) { if err := m.fail.trigger("GetDefault"); err != nil { return site.Site{}, err } for _, id := range m.defaults() { return m.withCount(m.sites[id]), nil } return site.Site{}, site.ErrSiteNotFound } func (m *mockSiteRepo) Create(s site.Site) (site.Site, error) { m.createCalls++ if err := m.fail.trigger("Create"); err != nil { return site.Site{}, err } if s.IsDefault { for id, other := range m.sites { other.IsDefault = false m.sites[id] = other } } m.nextID++ s.ID = m.nextID m.sites[s.ID] = s return m.withCount(s), nil } func (m *mockSiteRepo) Update(s site.Site) (site.Site, error) { if err := m.fail.trigger("Update"); err != nil { return site.Site{}, err } current, ok := m.sites[s.ID] if !ok { return site.Site{}, site.ErrSiteNotFound } current.Name, current.BaseURL, current.DefaultRobots = s.Name, s.BaseURL, s.DefaultRobots current.Locale, current.DefaultSocialImageID = s.Locale, s.DefaultSocialImageID current.SiteName, current.TitleTemplate, current.DefaultDescription = s.SiteName, s.TitleTemplate, s.DefaultDescription m.sites[s.ID] = current return m.withCount(current), nil } func (m *mockSiteRepo) SetDefault(id uint32) (site.Site, error) { m.setDefaultCalls++ if _, ok := m.sites[id]; !ok { return site.Site{}, site.ErrSiteNotFound } before := m.snapshot() for otherID, other := range m.sites { other.IsDefault = false m.sites[otherID] = other } if err := m.fail.trigger("SetDefault"); err != nil { m.sites = before return site.Site{}, err } flagged := m.sites[id] flagged.IsDefault = true m.sites[id] = flagged return m.withCount(flagged), nil } func (m *mockSiteRepo) DeleteMovingArticles(id uint32, reassignTo *uint32) error { m.deleteCalls++ m.deleteReassignments = append(m.deleteReassignments, reassignTo) if err := m.fail.trigger("DeleteMovingArticles"); err != nil { return err } target, ok := m.sites[id] if !ok { return site.ErrSiteNotFound } if target.IsDefault { return site.ErrDefaultSite } if m.withCount(target).ArticleCount > 0 && reassignTo == nil { return site.ErrSiteHasArticles } if reassignTo != nil { if _, exists := m.sites[*reassignTo]; !exists { return site.ErrSiteNotFound } for articleID, a := range m.articles.articles { if a.SiteID == id { a.SiteID = *reassignTo m.articles.articles[articleID] = a } } } delete(m.sites, id) return nil } func (m *mockSiteRepo) ClearImage(imageID uint32) ([]uint32, error) { if err := m.fail.trigger("ClearImage"); err != nil { return nil, err } cleared := []uint32{} for id, s := range m.sites { if s.DefaultSocialImageID != nil && *s.DefaultSocialImageID == imageID { s.DefaultSocialImageID = nil m.sites[id] = s cleared = append(cleared, id) } } sort.Slice(cleared, func(i, j int) bool { return cleared[i] < cleared[j] }) return cleared, nil } func (m *mockSiteRepo) AssignImage(siteIDs []uint32, imageID uint32) error { for _, id := range siteIDs { s := m.sites[id] assigned := imageID s.DefaultSocialImageID = &assigned m.sites[id] = s } return nil } func (m *mockSiteRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error) { if err := m.fail.trigger("RepointImage"); err != nil { return 0, err } updated := 0 for id, s := range m.sites { if s.DefaultSocialImageID == nil { continue } for _, from := range fromImageIDs { if *s.DefaultSocialImageID == from { target := toImageID s.DefaultSocialImageID = &target m.sites[id] = s updated++ break } } } return updated, nil } var _ site.Repository = &mockSiteRepo{} const ( siteMainID = 1 siteShopID = 2 siteBlogID = 3 ) type siteEnv struct { *producersEnv sites *mockSiteRepo } func newSiteEnv(t *testing.T) *siteEnv { t.Helper() p := newProducersEnv(t) sites := newMockSiteRepo(p.articles) recorder := p.audit.recorder renderer := &fakeBodyRenderer{} mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors()) c := p.container c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors()) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors()) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles) c.MergeMediaUseCase = mergeMedia c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, plainAuthors()) c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors()) 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) router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(c, p.settings)) p.router = router return &siteEnv{producersEnv: p, sites: sites} } func newSiteEnvWithMain(t *testing.T) *siteEnv { t.Helper() e := newSiteEnv(t) e.sites.seed(siteMainID, "Main", "main", true) return e } func newSiteEnvWithShop(t *testing.T) *siteEnv { t.Helper() e := newSiteEnvWithMain(t) e.sites.seed(siteShopID, "Shop", "shop", false) return e } func (e *siteEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() return e.as(t, apiKeyOwnerId, method, path, body) } func (e *siteEnv) anonymous(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() return e.as(t, 0, method, path, body) } func (e *siteEnv) seedArticle(id uint32, siteID uint32, online bool) { e.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: "Body", IsOnline: online, SiteID: siteID} } func (e *siteEnv) articlesOn(siteID uint32) int { total := 0 for _, a := range e.articles.articles { if a.SiteID == siteID { total++ } } return total } func bodyText(rec *httptest.ResponseRecorder) string { return strings.TrimSpace(rec.Body.String()) } func decodeSiteBody(t *testing.T, rec *httptest.ResponseRecorder) site.Site { t.Helper() var decoded site.Site if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode site %q: %v", rec.Body.String(), err) } return decoded } func decodeSiteList(t *testing.T, rec *httptest.ResponseRecorder) []site.Site { t.Helper() var decoded []site.Site if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode sites %q: %v", rec.Body.String(), err) } return decoded } func expectSiteStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status || bodyText(rec) != body { t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, bodyText(rec)) } } func stringPointer(value string) *string { return &value } func idPointer(value uint32) *uint32 { return &value } func plainSites() *mockSiteRepo { sites := newMockSiteRepo(newMockArticleRepo()) sites.seed(1, "Default", "default", true) return sites }