feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5365
-214
No files matched your search
@@ -0,0 +1,372 @@
|
||||
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
|
||||
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)
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites)
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites)
|
||||
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)
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user