feat: Sites feature backend

This commit is contained in:
Florian Sylvain committed 2026-10-14 03:56:50 +02:00
1 parent 45fcbd890d
commit a11338094e
98 files changed
+5365 -214

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) {
wanted []string
}{
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "media", "system", "users"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "media", "sites", "system", "users"}},
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
+1 -1
View File
@@ -83,7 +83,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock),
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock, plainSites()),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
}
+1 -1
View File
@@ -43,7 +43,7 @@ func newProducersEnv(t *testing.T) *producersEnv {
owner := env.users.users[apiKeyOwnerId]
owner.DisplayName = producersOwnerName
env.users.users[apiKeyOwnerId] = owner
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock)
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock, plainSites())
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
env.router = router
+5 -5
View File
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
t.Fatal(err)
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil {
t.Fatal(err)
@@ -187,7 +187,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) {
h := newActivityHarness(t)
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder)
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites())
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Title: "ab", Body: "Hello"})
@@ -383,8 +383,8 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
articles := newMockArticleRepo()
storage := newMockImageStorage()
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder)
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder)
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites())
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder, plainSites())
finished := make(chan struct{})
go func() {
+1 -1
View File
@@ -27,7 +27,7 @@ func openActivityDatabase(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("failed to open the in-memory database: %v", err)
}
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
t.Cleanup(func() {
+1 -1
View File
@@ -39,7 +39,7 @@ func newActivityWorld(t *testing.T) *activityWorld {
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner}
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"}
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock)
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites())
w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock)
w.export = useCases.NewExportActivityUseCase(h.probe, h.clock)
return w
+7 -7
View File
@@ -100,15 +100,15 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
mail := newMockMailRepo()
env.mail = mail
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder)
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites())
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
@@ -126,11 +126,11 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites()),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites()),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
+3 -3
View File
@@ -43,11 +43,11 @@ const (
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
getUserUseCase,
)
+3 -3
View File
@@ -22,11 +22,11 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
renderer := markdown.NewConverter()
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
getUserUseCase,
)
+3 -3
View File
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
+25 -1
View File
@@ -61,6 +61,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
if filters.IsOnline != nil && a.IsOnline != *filters.IsOnline {
continue
}
if filters.SiteID != nil && a.SiteID != *filters.SiteID {
continue
}
if filters.Status == article.StatusOnline && !a.IsOnline {
continue
}
@@ -190,6 +193,27 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
return a, nil
}
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
if err := m.fail.trigger("Update"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, article.ErrArticleNotFound
}
if update.Body != nil {
if err, failing := m.failUpdateBodyFor[id]; failing {
return article.Article{}, err
}
a.Body = *update.Body
}
if update.SiteID != nil {
a.SiteID = *update.SiteID
}
m.articles[id] = a
return a, nil
}
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
return article.Article{}, err
@@ -232,7 +256,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
+4 -4
View File
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
+3 -3
View File
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
+2 -2
View File
@@ -578,7 +578,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
t.Run("The typed sort reaches the repository and an absent sort keeps updated_at descending", func(t *testing.T) {
repo := newMockArticleRepo()
seedSortArticles(repo)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 {
t.Fatalf("unexpected %v %+v", err, sorted)
@@ -594,7 +594,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 3, 0)
repo.fail.failNextWith(method, sentinel)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
_, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
if !errors.Is(err, sentinel) {
t.Fatalf("%s: expected sentinel, got %v", method, err)
+3 -3
View File
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
+2
View File
@@ -31,3 +31,5 @@ func (f *failSwitch) trigger(method string) error {
}
return nil
}
const migrationPublicURL = "https://www.example.com"
+1 -1
View File
@@ -281,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+4 -4
View File
@@ -113,8 +113,8 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites()),
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
@@ -128,7 +128,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
VariantUsageTracker: e.tracker,
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
@@ -138,7 +138,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(e.images, e.variants, e.files, e.storage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(e.variants, serve, log.New(e.logs, "", 0)),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, hashing.NewSHA256KeyGenerator(), e.users, clock),
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock),
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock, plainSites()),
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder)
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
+8 -8
View File
@@ -62,27 +62,27 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites()),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites()),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites()),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+4 -4
View File
@@ -842,7 +842,7 @@ func TestMediaMerging(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "merged": 2}`)
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "sites_updated": 0, "merged": 2}`)
if got, want := env.articles.articles[1].Body, "a "+keep+" b "+keep; got != want {
t.Errorf("article 1 body: want %q, got %q", want, got)
}
@@ -866,7 +866,7 @@ func TestMediaMerging(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 0, "merged": 1}`)
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 0, "sites_updated": 0, "merged": 1}`)
})
t.Run("A copy that is gone answers 409 and changes nothing", func(t *testing.T) {
@@ -943,7 +943,7 @@ func TestMediaMerging(t *testing.T) {
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(t)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
@@ -1038,7 +1038,7 @@ func TestMediaSentinels(t *testing.T) {
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+1 -1
View File
@@ -270,7 +270,7 @@ func TestListMediaUseCasePaging(t *testing.T) {
build := func() (*mockImageRepo, *mockVariantFileRepo, *useCases.ListMediaUseCase) {
images := newMockImageRepo()
variants := newMockVariantFileRepo()
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants)
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants, plainSites())
}
seed := func(images *mockImageRepo, count int) {
for id := uint32(1); id <= uint32(count); id++ {
+1 -1
View File
@@ -591,7 +591,7 @@ func TestPurgePreview(t *testing.T) {
func TestPurgeRun(t *testing.T) {
counters := func(groups, purged, merged, articles, failed int) string {
return `{"groups_total":` + strconv.Itoa(groups) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"failed_groups":` + strconv.Itoa(failed) + `}`
return `{"groups_total":` + strconv.Itoa(groups) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"sites_updated":0,"failed_groups":` + strconv.Itoa(failed) + `}`
}
seedThreeGroups := func(env *mediaEnv) {
+2 -2
View File
@@ -31,9 +31,9 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder)
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites())
return &schedulerEnv{
mediaEnv: env,
runPurge: runPurge,
+6 -6
View File
@@ -32,8 +32,8 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder, plainSites())
}
t.Run("a non-matching fingerprint is a conflict and writes nothing", func(t *testing.T) {
@@ -114,7 +114,7 @@ func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
env.useImages(11, 5)
env.useImages(12, 3)
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites()).Preview()
var kept []uint32
for _, group := range preview.List {
@@ -132,7 +132,7 @@ func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
env.useImages(10, 1, 2, 3)
env.useImages(11, 5)
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites()).Preview()
sum := sha256.Sum256([]byte("1:2,3;5:4;"))
if err != nil || preview.Fingerprint != hex.EncodeToString(sum[:]) {
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
result, err := merge.Merge(testCtx, 1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
result, err := merge.Merge(testCtx, 1, []uint32{2})
+5 -5
View File
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID); err != nil {
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, 7)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Delete(testCtx, 7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
@@ -99,7 +99,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Delete(testCtx, created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err)
@@ -184,7 +184,7 @@ func TestDriverRegistries(t *testing.T) {
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil {
+2 -2
View File
@@ -7,10 +7,10 @@ import (
func TestMigrateTwiceKeepsSingleImagePathIndex(t *testing.T) {
db := openActivityDatabase(t)
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("second migrate failed: %v", err)
}
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("third migrate failed: %v", err)
}
var count int64
+3 -3
View File
@@ -22,7 +22,7 @@ func migratedMySQL(t *testing.T) (*mysqlTestServer, database.MySQLConfig, *gorm.
server := connectMySQLTestServer(t)
config := server.createDatabase(t)
connection := server.connect(t, config)
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
return server, config, connection.DB
@@ -67,7 +67,7 @@ func TestMySQLMigration(t *testing.T) {
t.Run("migrating twice changes nothing", func(t *testing.T) {
_, _, db := migratedMySQL(t)
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Errorf("the second migration must succeed: %v", err)
}
})
@@ -81,7 +81,7 @@ func TestMySQLMigration(t *testing.T) {
}
}
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
+3 -3
View File
@@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
+4 -4
View File
@@ -77,7 +77,7 @@ func openContractPostgres(t *testing.T) *gorm.DB {
t.Helper()
server := connectPostgresTestServer(t)
connection := server.connect(t, server.createDatabase(t))
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("migrate postgres: %v", err)
}
return connection.DB
@@ -88,7 +88,7 @@ func migratedPostgres(t *testing.T) (*postgresTestServer, database.PostgresConfi
server := connectPostgresTestServer(t)
config := server.createDatabase(t)
connection := server.connect(t, config)
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
return server, config, connection.DB
@@ -175,7 +175,7 @@ func TestPostgresMigration(t *testing.T) {
_, _, db := migratedPostgres(t)
before := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Errorf("the second migration must succeed: %v", err)
}
@@ -193,7 +193,7 @@ func TestPostgresMigration(t *testing.T) {
}
}
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
+3 -3
View File
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
+3 -3
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
+2 -2
View File
@@ -50,7 +50,7 @@ func openContractSQLite(t *testing.T) *gorm.DB {
t.Fatalf("open sqlite: %v", err)
}
connection.DB.Logger = logger.Discard
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
t.Cleanup(func() { _ = connection.Close() })
@@ -111,7 +111,7 @@ func openContractMySQL(t *testing.T) *gorm.DB {
t.Helper()
server := connectMySQLTestServer(t)
connection := server.connect(t, server.createDatabase(t))
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("migrate mysql: %v", err)
}
return connection.DB
+6 -6
View File
@@ -129,10 +129,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
@@ -142,10 +142,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo()),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
}
}
+669
View File
@@ -0,0 +1,669 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/site"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
const (
siteOwnerOnlyBody = "Only the owner can do this."
siteSessionOnlyBody = "This action needs a real session."
)
func TestSitesReading(t *testing.T) {
t.Run("The list puts the default site first and then orders by name", func(t *testing.T) {
e := newSiteEnv(t)
e.sites.seed(4, "Zeta", "zeta", false)
e.sites.seed(5, "Alpha", "alpha", false)
e.sites.seed(6, "Main", "main", true)
e.seedArticle(1, 5, true)
e.seedArticle(2, 5, false)
rec := e.owner(t, http.MethodGet, "/v1/site/", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
listed := decodeSiteList(t, rec)
names := []string{}
for _, s := range listed {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"Main", "Alpha", "Zeta"}) {
t.Fatalf("order = %v", names)
}
if listed[1].ArticleCount != 2 || listed[0].ArticleCount != 0 {
t.Errorf("article_count = %+v", listed)
}
if !strings.Contains(rec.Body.String(), `"article_count"`) {
t.Errorf("article_count missing: %s", rec.Body.String())
}
})
t.Run("Who can read the list", func(t *testing.T) {
cases := []struct {
credentials string
send func(e *siteEnv, t *testing.T) int
status int
}{
{"the owner's jwt cookie", func(e *siteEnv, t *testing.T) int {
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/site/", "").Code
}, http.StatusOK},
{"a verified admin's jwt cookie", func(e *siteEnv, t *testing.T) int {
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "").Code
}, http.StatusOK},
{"a valid API key", func(e *siteEnv, t *testing.T) int {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodGet, "/v1/site/", "").Code
}, http.StatusOK},
{"an unverified account's jwt cookie", func(e *siteEnv, t *testing.T) int {
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/site/", "").Code
}, http.StatusForbidden},
{"no credentials", func(e *siteEnv, t *testing.T) int {
return e.anonymous(t, http.MethodGet, "/v1/site/", "").Code
}, http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.credentials, func(t *testing.T) {
e := newSiteEnvWithMain(t)
if got := c.send(e, t); got != c.status {
t.Errorf("status = %d, expected %d", got, c.status)
}
})
}
})
t.Run("A site without image or locale returns nulls", func(t *testing.T) {
e := newSiteEnvWithMain(t)
e.sites.seed(siteBlogID, "Blog", "blog", false)
rec := e.owner(t, http.MethodGet, "/v1/site/", "")
if !strings.Contains(rec.Body.String(), `"default_social_image_id":null`) || !strings.Contains(rec.Body.String(), `"locale":null`) {
t.Errorf("expected nulls in %s", rec.Body.String())
}
})
}
func TestSitesCreating(t *testing.T) {
const shopBody = `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`
t.Run("The owner creates a site", func(t *testing.T) {
e := newSiteEnvWithMain(t)
body := `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "noindex", "locale": "fr-CA"}`
rec := e.owner(t, http.MethodPost, "/v1/site/", body)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
created := decodeSiteBody(t, rec)
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA")}
if created.ID == 0 || !reflect.DeepEqual(created, want) {
t.Errorf("created = %+v", created)
}
if stored, ok := e.sites.sites[created.ID]; !ok || stored.Key != "shop" {
t.Errorf("site not stored: %+v", e.sites.sites)
}
})
t.Run("Optional fields default", func(t *testing.T) {
e := newSiteEnvWithMain(t)
created := decodeSiteBody(t, e.owner(t, http.MethodPost, "/v1/site/", shopBody))
if created.DefaultRobots != "index" || created.Locale != nil || created.DefaultSocialImageID != nil || created.IsDefault {
t.Errorf("created = %+v", created)
}
})
t.Run("A new site can become the default at creation", func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "is_default": true}`)
created := decodeSiteBody(t, rec)
if rec.Code != http.StatusCreated || !created.IsDefault || e.sites.sites[siteMainID].IsDefault {
t.Errorf("shop %+v, main %+v", created, e.sites.sites[siteMainID])
}
if defaults := e.sites.defaults(); !reflect.DeepEqual(defaults, []uint32{created.ID}) {
t.Errorf("defaults = %v", defaults)
}
})
t.Run("The base URL is normalised", func(t *testing.T) {
cases := [][2]string{
{"https://shop.example.com/", "https://shop.example.com"},
{"https://Shop.Example.com", "https://shop.example.com"},
{"http://localhost:5173/", "http://localhost:5173"},
}
for _, c := range cases {
t.Run(c[0], func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "`+c[0]+`"}`)
if got := decodeSiteBody(t, rec).BaseURL; rec.Code != http.StatusCreated || got != c[1] {
t.Errorf("status %d, base_url %q, expected %q", rec.Code, got, c[1])
}
})
}
})
t.Run("The base URL must be https except loopback and carry no path", func(t *testing.T) {
cases := []struct {
sent string
status int
body string
}{
{"https://shop.example.com", 201, ""},
{"http://localhost:8080", 201, ""},
{"http://127.0.0.1:8080", 201, ""},
{"http://[::1]:8080", 201, ""},
{"http://shop.example.com", 400, "invalid_site_url"},
{"https://shop.example.com/blog", 400, "invalid_site_url"},
{"https://shop.example.com/?a=1", 400, "invalid_site_url"},
{"https://user@shop.example.com", 400, "invalid_site_url"},
{"ftp://shop.example.com", 400, "invalid_site_url"},
{"shop.example.com", 400, "invalid_site_url"},
}
for _, c := range cases {
t.Run(c.sent, func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "`+c.sent+`"}`)
if rec.Code != c.status {
t.Fatalf("status = %d, expected %d: %s", rec.Code, c.status, rec.Body.String())
}
if c.body != "" && bodyText(rec) != c.body {
t.Errorf("body = %q, expected %q", bodyText(rec), c.body)
}
})
}
})
t.Run("The key format", func(t *testing.T) {
cases := []struct {
key string
status int
}{
{"shop", 201}, {"my-shop-2", 201}, {"ab", 201},
{"a", 400}, {"Shop", 400}, {"-shop", 400}, {"shop-", 400}, {"my--shop", 400}, {"my_shop", 400},
{strings.Repeat("a", 33), 400}, {"", 400},
{strings.Repeat("a", 32), 201},
}
for _, c := range cases {
t.Run("key "+strconv.Quote(c.key), func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "`+c.key+`", "base_url": "https://shop.example.com"}`)
if rec.Code != c.status {
t.Errorf("status = %d, expected %d: %s", rec.Code, c.status, rec.Body.String())
}
})
}
})
t.Run("A taken key is refused", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", shopBody)
expectSiteStatusAndBody(t, rec, http.StatusConflict, "site_key_taken")
if len(e.sites.sites) != 2 {
t.Errorf("a site was added: %+v", e.sites.sites)
}
})
t.Run("Other fields are validated", func(t *testing.T) {
cases := []struct {
name string
body string
status int
token string
}{
{"name empty", `{"name": "", "key": "shop", "base_url": "https://shop.example.com"}`, 400, ""},
{"name 101 characters", `{"name": "` + strings.Repeat("n", 101) + `", "key": "shop", "base_url": "https://shop.example.com"}`, 400, ""},
{"default_robots follow", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "follow"}`, 400, ""},
{"locale english", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": "english"}`, 400, "invalid_locale"},
{"locale fr_FR", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": "fr_FR"}`, 400, "invalid_locale"},
{"default_social_image_id 999", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_social_image_id": 999}`, 400, "image_not_found"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", c.body)
if rec.Code != c.status {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
if c.token != "" && bodyText(rec) != c.token {
t.Errorf("body = %q, expected %q", bodyText(rec), c.token)
}
if c.token == "" && (bodyText(rec) == "" || strings.Contains(bodyText(rec), "_")) {
t.Errorf("expected the validator error text, got %q", bodyText(rec))
}
if len(e.sites.sites) != 1 {
t.Errorf("a site was added: %+v", e.sites.sites)
}
})
}
})
t.Run("A malformed body is refused", func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", "{not json")
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if len(e.sites.sites) != 1 {
t.Errorf("a site was added")
}
})
t.Run("A valid locale is accepted and an empty one clears it", func(t *testing.T) {
cases := []struct {
sent string
stored *string
}{
{`"en"`, stringPointer("en")},
{`"fr-CA"`, stringPointer("fr-CA")},
{`"zh-Hant-TW"`, stringPointer("zh-Hant-TW")},
{`""`, nil},
{`null`, nil},
}
for _, c := range cases {
t.Run(c.sent, func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": `+c.sent+`}`)
if rec.Code != http.StatusCreated || !reflect.DeepEqual(decodeSiteBody(t, rec).Locale, c.stored) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
}
})
}
func TestSitesEditing(t *testing.T) {
t.Run("The owner edits a site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr"}`)
updated := decodeSiteBody(t, rec)
want := site.Site{ID: 2, Name: "Boutique", Key: "shop", BaseURL: "https://boutique.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr")}
if rec.Code != http.StatusOK || !reflect.DeepEqual(updated, want) {
t.Errorf("status %d, updated %+v", rec.Code, updated)
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newSiteEnvWithShop(t)
shop := e.sites.sites[siteShopID]
shop.Locale, shop.DefaultRobots = stringPointer("fr"), "noindex"
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
want := shop
want.Name = "Boutique"
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
t.Errorf("status %d, site %+v", rec.Code, e.sites.sites[siteShopID])
}
})
t.Run("The default social image is set and cleared", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
set := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
afterSet := e.sites.sites[siteShopID].DefaultSocialImageID
cleared := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": null}`)
if set.Code != http.StatusOK || afterSet == nil || *afterSet != 5 {
t.Errorf("set: status %d, image %v", set.Code, afterSet)
}
if cleared.Code != http.StatusOK || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
t.Errorf("clear: status %d, image %v", cleared.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
}
})
t.Run("The key never changes", func(t *testing.T) {
cases := []struct {
key string
status int
body string
}{{"shop", 200, ""}, {"store", 400, "key_immutable"}}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"key": "`+c.key+`"}`)
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) {
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
}
if e.sites.sites[siteShopID].Key != "shop" {
t.Errorf("key changed to %q", e.sites.sites[siteShopID].Key)
}
})
}
})
t.Run("A rejected key change leaves every other field unchanged", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "key": "store"}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "key_immutable")
if e.sites.sites[siteShopID].Name != "Shop" {
t.Errorf("name changed to %q", e.sites.sites[siteShopID].Name)
}
})
t.Run("The default flag is not editable here", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"is_default": true}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "use_set_default")
if !reflect.DeepEqual(e.sites.defaults(), []uint32{siteMainID}) {
t.Errorf("defaults = %v", e.sites.defaults())
}
})
t.Run("Editing is validated like creation", func(t *testing.T) {
cases := []struct {
name string
body string
token string
}{
{"base_url http", `{"base_url": "http://shop.example.com"}`, "invalid_site_url"},
{"base_url path", `{"base_url": "https://shop.example.com/x"}`, "invalid_site_url"},
{"locale fr_FR", `{"locale": "fr_FR"}`, "invalid_locale"},
{"default_social_image_id 999", `{"default_social_image_id": 999}`, "image_not_found"},
{"name empty", `{"name": ""}`, ""},
{"default_robots follow", `{"default_robots": "follow"}`, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSiteEnvWithShop(t)
before := e.sites.snapshot()
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if c.token != "" && bodyText(rec) != c.token {
t.Errorf("body = %q, expected %q", bodyText(rec), c.token)
}
if c.token == "" && (bodyText(rec) == "" || strings.Contains(bodyText(rec), "_")) {
t.Errorf("expected the validator error text, got %q", bodyText(rec))
}
if !reflect.DeepEqual(before, e.sites.snapshot()) {
t.Errorf("site changed")
}
})
}
})
t.Run("A missing site is not found", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/999", `{"name": "X"}`)
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
})
t.Run("A no-op edit records nothing", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Shop", "base_url": "https://shop.example.com/", "default_robots": "index", "locale": null}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
e.noEvent(t)
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
}
func TestSitesDefault(t *testing.T) {
t.Run("The owner makes another site the default", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
got := decodeSiteBody(t, rec)
if rec.Code != http.StatusOK || got.Name != "Shop" || !got.IsDefault || e.sites.sites[siteMainID].IsDefault {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
t.Run("Setting the current default is a no-op", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/1/default", "")
if rec.Code != http.StatusOK || e.sites.setDefaultCalls != 0 {
t.Errorf("status %d, setDefault calls %d", rec.Code, e.sites.setDefaultCalls)
}
e.noEvent(t)
})
t.Run("A missing site cannot become the default", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/999/default", "")
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
})
t.Run("The swap is atomic", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.sites.fail.failNext("SetDefault")
rec := e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
if rec.Code != http.StatusInternalServerError || !reflect.DeepEqual(e.sites.defaults(), []uint32{siteMainID}) {
t.Errorf("status %d, defaults %v", rec.Code, e.sites.defaults())
}
})
t.Run("New articles start on the new default", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"site_id":2`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
}
func TestSitesDeleting(t *testing.T) {
t.Run("A site without articles is deleted", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodDelete, "/v1/site/2", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if _, exists := e.sites.sites[siteShopID]; exists {
t.Errorf("site 2 still exists")
}
})
t.Run("A site with articles needs a destination", func(t *testing.T) {
e := newSiteEnvWithShop(t)
for id := uint32(1); id <= 3; id++ {
e.seedArticle(id, siteShopID, true)
}
before := e.sites.snapshot()
rec := e.owner(t, http.MethodDelete, "/v1/site/2", "")
expectSiteStatusAndBody(t, rec, http.StatusConflict, "site_has_articles")
if !reflect.DeepEqual(before, e.sites.snapshot()) || e.articlesOn(siteShopID) != 3 {
t.Errorf("state changed")
}
})
t.Run("The articles move in one transaction", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.sites.seed(siteBlogID, "Blog", "blog", false)
for id := uint32(1); id <= 3; id++ {
e.seedArticle(id, siteShopID, true)
}
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
if rec.Code != http.StatusNoContent || e.articlesOn(siteBlogID) != 3 {
t.Errorf("status %d, articles on 3: %d", rec.Code, e.articlesOn(siteBlogID))
}
if _, exists := e.sites.sites[siteShopID]; exists {
t.Errorf("site 2 still exists")
}
})
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.sites.seed(siteBlogID, "Blog", "blog", false)
e.seedArticle(1, siteShopID, true)
e.sites.fail.failNext("DeleteMovingArticles")
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
if rec.Code != http.StatusInternalServerError || e.articlesOn(siteShopID) != 1 {
t.Errorf("status %d, articles on 2: %d", rec.Code, e.articlesOn(siteShopID))
}
if _, exists := e.sites.sites[siteShopID]; !exists {
t.Errorf("site 2 vanished")
}
})
t.Run("The articles are moved by one repository operation", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.sites.seed(siteBlogID, "Blog", "blog", false)
e.seedArticle(1, siteShopID, true)
e.articles.fail.failNext("Update")
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
if rec.Code != http.StatusNoContent || e.sites.deleteCalls != 1 || e.sites.deleteReassignments[0] == nil || *e.sites.deleteReassignments[0] != siteBlogID {
t.Errorf("status %d, delete calls %d", rec.Code, e.sites.deleteCalls)
}
})
t.Run("An unusable destination", func(t *testing.T) {
cases := []struct {
reassign string
status int
body string
}{
{"999", 404, "site_not_found"},
{"2", 400, "invalid_reassign"},
{"abc", 400, "invalid_reassign"},
{"-1", 400, "invalid_reassign"},
}
for _, c := range cases {
t.Run(c.reassign, func(t *testing.T) {
e := newSiteEnvWithShop(t)
for id := uint32(1); id <= 3; id++ {
e.seedArticle(id, siteShopID, true)
}
before := e.sites.snapshot()
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to="+c.reassign, "")
expectSiteStatusAndBody(t, rec, c.status, c.body)
if !reflect.DeepEqual(before, e.sites.snapshot()) || e.articlesOn(siteShopID) != 3 {
t.Errorf("state changed")
}
})
}
})
t.Run("The default site cannot be deleted", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodDelete, "/v1/site/1", "")
expectSiteStatusAndBody(t, rec, http.StatusForbidden, "default_site")
})
t.Run("The default check comes before the articles check", func(t *testing.T) {
e := newSiteEnvWithShop(t)
for id := uint32(1); id <= 5; id++ {
e.seedArticle(id, siteMainID, true)
}
rec := e.owner(t, http.MethodDelete, "/v1/site/1", "")
expectSiteStatusAndBody(t, rec, http.StatusForbidden, "default_site")
})
t.Run("A missing site is not found", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodDelete, "/v1/site/999", "")
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
})
t.Run("An unused reassign_to is ignored", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.sites.seed(siteBlogID, "Blog", "blog", false)
blogBefore := e.sites.sites[siteBlogID]
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
if rec.Code != http.StatusNoContent || !reflect.DeepEqual(e.sites.sites[siteBlogID], blogBefore) {
t.Errorf("status %d", rec.Code)
}
})
}
func TestSitesOnlyTheOwnerWrites(t *testing.T) {
t.Run("Other roles and keys are refused", func(t *testing.T) {
requests := []struct{ method, path, body string }{
{http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`},
{http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`},
{http.MethodPatch, "/v1/site/2/default", ""},
{http.MethodDelete, "/v1/site/2", ""},
}
for _, request := range requests {
t.Run(request.method+" "+request.path+" with a verified admin's jwt cookie", func(t *testing.T) {
e := newSiteEnvWithShop(t)
before := e.sites.snapshot()
rec := e.as(t, apiKeyDevId, request.method, request.path, request.body)
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
if !reflect.DeepEqual(before, e.sites.snapshot()) {
t.Errorf("a site changed")
}
})
t.Run(request.method+" "+request.path+" with a valid API key", func(t *testing.T) {
e := newSiteEnvWithShop(t)
before := e.sites.snapshot()
raw, _ := e.issueKey(apiKeyOwnerId, "Deploy bot")
rec := e.asKey(t, raw, request.method, request.path, request.body)
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteSessionOnlyBody)
if !reflect.DeepEqual(before, e.sites.snapshot()) {
t.Errorf("a site changed")
}
})
}
})
t.Run("The role is checked before the body", func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", "{not json")
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
})
}
+211
View File
@@ -0,0 +1,211 @@
package test
import (
"RenewCMS/internal/domain/audit"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
)
const genericFailureSentence = "Something went wrong on our side. Please try again."
func TestSitesActivityLog(t *testing.T) {
t.Run("A successful write is recorded once after the change", func(t *testing.T) {
cases := []struct {
request string
action string
prepare func(e *siteEnv)
method string
path string
body string
status int
}{
{"POST /v1/site/ for \"Shop\"", "site.create", func(e *siteEnv) {}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`, http.StatusCreated},
{"PATCH /v1/site/2 renaming it", "site.edit", func(e *siteEnv) {
e.sites.seed(siteShopID, "Boutique", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"name": "Shop"}`, http.StatusOK},
{"PATCH /v1/site/2/default", "site.set_default", func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2/default", "", http.StatusOK},
{"DELETE /v1/site/2", "site.delete", func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodDelete, "/v1/site/2", "", http.StatusNoContent},
}
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithMain(t)
c.prepare(e)
rec := e.owner(t, c.method, c.path, c.body)
if rec.Code != c.status {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
event := e.single(t)
assertProducerEvent(t, event, c.action, audit.CategorySites, audit.OutcomeSuccess)
assertProducerTarget(t, event, "site", "2", "Shop")
assertProducerOwnerOrigin(t, event)
if event.Category != "sites" {
t.Errorf("category = %s", event.Category)
}
})
}
})
t.Run("A refused write is recorded as failed with a plain reason", func(t *testing.T) {
cases := []struct {
request string
action string
reason string
status int
prepare func(e *siteEnv)
method string
path string
body string
}{
{"POST /v1/site/ with a taken key", "site.create", "A site with that key already exists.", http.StatusConflict, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "shop", "base_url": "https://other.example.com"}`},
{"PATCH /v1/site/2 changing the key", "site.edit", "The key of a site cannot be changed.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"key": "store"}`},
{"DELETE /v1/site/1 on the default site", "site.delete", "The default site cannot be deleted.", http.StatusForbidden, func(e *siteEnv) {}, http.MethodDelete, "/v1/site/1", ""},
{"DELETE /v1/site/2 with articles", "site.delete", "The site still has articles.", http.StatusConflict, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
e.seedArticle(1, siteShopID, true)
}, http.MethodDelete, "/v1/site/2", ""},
{"POST /v1/site/ with an invalid base URL", "site.create", "The site address is not valid.", http.StatusBadRequest, func(e *siteEnv) {}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "http://shop.example.com"}`},
{"PATCH /v1/site/2 with an invalid locale", "site.edit", "The locale is not valid.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"locale": "not a locale"}`},
{"PATCH /v1/site/2 with is_default", "site.edit", "Use the default site action to change the default.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"is_default": true}`},
{"DELETE /v1/site/2 reassigned to itself", "site.delete", "The destination site is not valid.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
e.seedArticle(1, siteShopID, true)
}, http.MethodDelete, "/v1/site/2?reassign_to=2", ""},
{"POST /v1/site/ when the repository fails with \"db is locked\"", "site.create", genericFailureSentence, http.StatusInternalServerError, func(e *siteEnv) {
e.sites.fail.failNextWith("Create", errors.New("db is locked"))
}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"PATCH /v1/site/2/default when the repository fails", "site.set_default", genericFailureSentence, http.StatusInternalServerError, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
e.sites.fail.failNext("SetDefault")
}, http.MethodPatch, "/v1/site/2/default", ""},
}
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithMain(t)
c.prepare(e)
rec := e.owner(t, c.method, c.path, c.body)
if rec.Code != c.status {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
event := e.single(t)
assertProducerEvent(t, event, c.action, audit.CategorySites, audit.OutcomeFailed)
assertProducerFailure(t, event, c.reason)
})
}
})
t.Run("Nothing is recorded when there is nothing to attribute", func(t *testing.T) {
cases := []struct {
request string
as uint32
method string
path string
body string
}{
{"PATCH /v1/site/999 as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/999", `{"name": "X"}`},
{"DELETE /v1/site/999 as the owner", apiKeyOwnerId, http.MethodDelete, "/v1/site/999", ""},
{"POST /v1/site/ with an unknown image", apiKeyOwnerId, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "other", "base_url": "https://other.example.com", "default_social_image_id": 999}`},
{"POST /v1/site/ as an admin (403)", apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"POST /v1/site/ with no credentials (401)", 0, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"PATCH /v1/site/abc as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`},
{"POST /v1/site/ with a malformed body", apiKeyOwnerId, http.MethodPost, "/v1/site/", "{not json"},
}
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.as(t, c.as, c.method, c.path, c.body)
e.noEvent(t)
})
}
})
t.Run("A failing audit repository never changes the response", func(t *testing.T) {
body := `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`
healthy := newSiteEnvWithMain(t)
healthyRec := healthy.owner(t, http.MethodPost, "/v1/site/", body)
broken := newSiteEnvWithMain(t)
broken.audit.probe.appendErr = errDiskFull
rec := broken.owner(t, http.MethodPost, "/v1/site/", body)
if rec.Code != healthyRec.Code || rec.Body.String() != healthyRec.Body.String() {
t.Errorf("broken %d %s, healthy %d %s", rec.Code, rec.Body.String(), healthyRec.Code, healthyRec.Body.String())
}
if len(broken.sites.sites) != 2 {
t.Errorf("the site was not created")
}
})
t.Run("Reads are never recorded", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.owner(t, http.MethodGet, "/v1/site/", "")
e.noEvent(t)
})
t.Run("The key is never a change field", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr", "default_social_image_id": 5}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
fields := []string{}
for _, change := range e.single(t).Changes {
fields = append(fields, change.Field)
}
if !reflect.DeepEqual(fields, []string{"name", "base_url", "default_robots", "locale", "default_social_image"}) {
t.Errorf("fields = %v", fields)
}
})
t.Run("The activity API lists site events", func(t *testing.T) {
e := newSiteEnvWithMain(t)
e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`)
e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
rec := e.owner(t, http.MethodGet, "/v1/activity/?category=sites", "")
var page struct {
Items []struct {
Action string `json:"action"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
actions := []string{}
for _, item := range page.Items {
if item.Category != "sites" {
t.Errorf("item category = %s", item.Category)
}
actions = append(actions, item.Action)
}
if len(actions) != 2 || !strings.Contains(strings.Join(actions, ","), "site.create") || !strings.Contains(strings.Join(actions, ","), "site.set_default") {
t.Errorf("actions = %v", actions)
}
})
}
+346
View File
@@ -0,0 +1,346 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type articleListBody struct {
Items []struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Online bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
} `json:"items"`
Total int `json:"total"`
Counts struct {
All int `json:"all"`
Online int `json:"online"`
Draft int `json:"draft"`
} `json:"counts"`
}
func decodeArticleList(t *testing.T, rec *httptest.ResponseRecorder) articleListBody {
t.Helper()
var decoded articleListBody
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode list %q: %v", rec.Body.String(), err)
}
return decoded
}
func (e *siteEnv) seedTitled(id uint32, siteID uint32, title string, online bool) {
e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", IsOnline: online, SiteID: siteID}
}
func TestArticlesBelongToASite(t *testing.T) {
t.Run("A new article starts on the default site", func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
stored := e.articles.articles[1]
if rec.Code != http.StatusCreated || stored.SiteID != siteMainID || stored.IsOnline {
t.Errorf("status %d, stored %+v", rec.Code, stored)
}
})
t.Run("Article reads carry site_id", func(t *testing.T) {
requests := []struct{ name, method, path, body string }{
{"GET /v1/article/8", http.MethodGet, "/v1/article/8", ""},
{"GET /v1/article/ (each item of items)", http.MethodGet, "/v1/article/", ""},
{"PATCH /v1/article/8/publish", http.MethodPatch, "/v1/article/8/publish", ""},
{"PATCH /v1/article/8", http.MethodPatch, "/v1/article/8", `{"body": "New"}`},
}
for _, request := range requests {
t.Run(request.name, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", false)
rec := e.owner(t, request.method, request.path, request.body)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"site_id":2`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
}
t.Run("POST /v1/article", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"site_id":1`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
})
t.Run("The list filters by site key", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(1, siteShopID, "Shop one", true)
e.seedTitled(2, siteShopID, "Shop two", false)
for id := uint32(3); id <= 5; id++ {
e.seedTitled(id, siteMainID, "Main article", true)
}
list := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/?site=shop", ""))
if list.Total != 2 || len(list.Items) != 2 {
t.Fatalf("list = %+v", list)
}
for _, item := range list.Items {
if item.SiteID != siteShopID {
t.Errorf("item %+v is not on shop", item)
}
}
})
t.Run("The filter composes with the other parameters", func(t *testing.T) {
cases := []struct {
params string
holds func(title string, online bool) bool
min int
}{
{"status=online", func(_ string, online bool) bool { return online }, 1},
{"status=draft", func(_ string, online bool) bool { return !online }, 1},
{"q=spring", func(title string, _ bool) bool { return strings.Contains(strings.ToLower(title), "spring") }, 1},
{"sort=title:asc", func(string, bool) bool { return true }, 2},
{"size=5&page=2", func(string, bool) bool { return true }, 1},
{"status=draft&q=launch", func(title string, online bool) bool {
return !online && strings.Contains(strings.ToLower(title), "launch")
}, 1},
}
for _, c := range cases {
t.Run(c.params, func(t *testing.T) {
e := newSiteEnvWithShop(t)
titles := []string{"Spring launch", "Spring notes", "Launch plan", "Summer", "Autumn", "Winter", "Zeta"}
for index, title := range titles {
e.seedTitled(uint32(index+1), siteShopID, title, index%2 == 0)
}
e.seedTitled(20, siteShopID, "Launch of spring draft", false)
e.seedTitled(30, siteMainID, "Spring launch main draft", false)
e.seedTitled(31, siteMainID, "Main online", true)
list := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/?site=shop&"+c.params, ""))
if len(list.Items) < c.min {
t.Fatalf("expected at least %d items, got %+v", c.min, list)
}
for _, item := range list.Items {
if item.SiteID != siteShopID || !c.holds(item.Title, item.Online) {
t.Errorf("item %+v does not satisfy shop and %s", item, c.params)
}
}
})
}
})
t.Run("Unknown or empty site keys", func(t *testing.T) {
t.Run("nope", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(1, siteShopID, "Shop one", true)
e.seedTitled(2, siteMainID, "Main draft", false)
global := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/", "")).Counts
rec := e.owner(t, http.MethodGet, "/v1/article/?site=nope", "")
list := decodeArticleList(t, rec)
if rec.Code != http.StatusOK || len(list.Items) != 0 || list.Total != 0 || list.Counts != global {
t.Errorf("status %d, list %+v, global %+v", rec.Code, list, global)
}
})
t.Run("(empty)", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodGet, "/v1/article/?site=", "")
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
})
})
t.Run("An anonymous or unverified list is filtered by site and shows online articles only", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(1, siteShopID, "Shop online", true)
e.seedTitled(2, siteShopID, "Shop offline", false)
for name, rec := range map[string]*httptest.ResponseRecorder{
"anonymous": e.anonymous(t, http.MethodGet, "/v1/article/?site=shop", ""),
"unverified": e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/?site=shop", ""),
} {
list := decodeArticleList(t, rec)
if rec.Code != http.StatusOK || len(list.Items) != 1 || list.Items[0].ID != 1 {
t.Errorf("%s: status %d, list %+v", name, rec.Code, list)
}
}
})
t.Run("An offline article is still not found publicly", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(9, siteShopID, "Hidden", false)
rec := e.anonymous(t, http.MethodGet, "/v1/article/9", "")
expectSiteStatusAndBody(t, rec, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
})
t.Run("An article moves to another site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
if rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID {
t.Errorf("status %d, stored %+v", rec.Code, e.articles.articles[8])
}
})
t.Run("Any role that can edit the article may choose its site", func(t *testing.T) {
credentials := map[string]func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder{
"the owner's jwt cookie": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
},
"a verified admin's jwt cookie": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
},
"a valid API key": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
},
}
for name, send := range credentials {
t.Run(name, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
if rec := send(e, t); rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("The body and the site change together", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New body", "site_id": 2}`)
stored := e.articles.articles[8]
if rec.Code != http.StatusOK || stored.Body != "New body" || stored.SiteID != siteShopID {
t.Errorf("status %d, stored %+v", rec.Code, stored)
}
})
t.Run("An update without site_id keeps the site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New body"}`)
if rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID || e.articles.articles[8].Body != "New body" {
t.Errorf("status %d, stored %+v", rec.Code, e.articles.articles[8])
}
})
t.Run("An unusable site_id is refused", func(t *testing.T) {
cases := []struct {
value string
status int
body string
}{
{"999", 404, "site_not_found"},
{"0", 400, ""},
{"-1", 400, apperr.BodyErrorMessage},
{`"a"`, 400, apperr.BodyErrorMessage},
}
for _, c := range cases {
t.Run(c.value, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
before := e.articles.articles[8]
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": `+c.value+`}`)
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) || (c.body == "" && bodyText(rec) == "") {
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
}
if e.articles.articles[8] != before {
t.Errorf("article changed: %+v", e.articles.articles[8])
}
e.noEvent(t)
})
}
})
t.Run("A site cannot be removed from an article", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": null}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if e.articles.articles[8].SiteID != siteShopID {
t.Errorf("article changed")
}
})
t.Run("Moving to the same site records nothing", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("An empty or unknown update is refused", func(t *testing.T) {
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"title": "New title"}`, `{"body": null}`} {
t.Run(body, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
before := e.articles.articles[8]
rec := e.owner(t, http.MethodPatch, "/v1/article/8", body)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if e.articles.articles[8] != before {
t.Errorf("article changed")
}
e.noEvent(t)
})
}
})
t.Run("is_online is independent of the site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
if !e.articles.articles[8].IsOnline {
t.Errorf("article went offline")
}
})
t.Run("Articles keep working after a site rename", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
rec := e.owner(t, http.MethodGet, "/v1/article/8", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"site_id":2`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
t.Run("A site change is recorded in article.edit with site names", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
event := e.single(t)
assertProducerEvent(t, event, "article.edit", "articles", "success")
if got := changesJSON(t, event); got != `[{"field":"site","kind":"text","before":"Main","after":"Shop"}]` {
t.Errorf("changes = %s", got)
}
})
}
+471
View File
@@ -0,0 +1,471 @@
package test
import (
siteHandler "RenewCMS/api/handlers/site"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"bytes"
"encoding/json"
"errors"
"github.com/go-chi/chi/v5"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"gorm.io/gorm"
)
func imageChange(t *testing.T, e *siteEnv) (string, string) {
t.Helper()
changes := e.single(t).Changes
if len(changes) != 1 || changes[0].Field != "default_social_image" || changes[0].Before == nil || changes[0].After == nil {
t.Fatalf("changes = %+v", changes)
}
return *changes[0].Before, *changes[0].After
}
func TestSitesImageChangeLabels(t *testing.T) {
t.Run("Switching between two images records both filenames", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "a")
e.seedBanner(6, "other.png", "b")
e.useImageOnSite(siteShopID, 5)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 6}`)
before, after := imageChange(t, e)
if rec.Code != http.StatusOK || before != "banner.png" || after != "other.png" {
t.Errorf("status %d, before %q, after %q", rec.Code, before, after)
}
})
t.Run("Setting the same image again records nothing", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "a")
e.useImageOnSite(siteShopID, 5)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("An image without a filename is labelled by its id", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "a")
e.seedBanner(6, "", "b")
e.useImageOnSite(siteShopID, 6)
e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
before, after := imageChange(t, e)
if before != "6" || after != "banner.png" {
t.Errorf("before %q, after %q", before, after)
}
})
t.Run("A previous image that no longer exists is labelled by its id", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "a")
e.useImageOnSite(siteShopID, 99)
e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
before, after := imageChange(t, e)
if before != "99" || after != "banner.png" {
t.Errorf("before %q, after %q", before, after)
}
})
}
func TestSitesImageUsageCounting(t *testing.T) {
t.Run("Renaming an image used by an article and a site counts both", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "a")
e.useImageOnSite(siteShopID, 5)
e.articles.articles[1] = articleUsingImage(5)
rec := e.owner(t, http.MethodPatch, "/v1/media/5", `{"filename": "renamed"}`)
var body struct {
UsedIn int `json:"used_in"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if rec.Code != http.StatusOK || body.UsedIn != 2 {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
t.Run("A purge keeps the copy that only a site uses", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedDuplicates()
e.useImageOnSite(siteShopID, 6)
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
var body map[string]int
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if _, kept := e.images.images[6]; rec.Code != http.StatusOK || body["merged"] != 1 || !kept {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
if _, gone := e.images.images[5]; gone {
t.Errorf("the unused copy was kept")
}
})
}
var errInjectedDatabaseFailure = errors.New("injected database failure")
type databaseFault struct {
armed atomic.Bool
}
func injectDatabaseFault(db *gorm.DB, name string, register func(db *gorm.DB, name string, fn func(*gorm.DB))) *databaseFault {
fault := &databaseFault{}
register(db, name, func(tx *gorm.DB) {
if fault.armed.Load() {
_ = tx.AddError(errInjectedDatabaseFailure)
}
})
return fault
}
func faultOnUpdate(db *gorm.DB, name string, fn func(*gorm.DB)) {
_ = db.Callback().Update().Before("gorm:update").Register(name, fn)
}
func faultOnQuery(db *gorm.DB, name string, fn func(*gorm.DB)) {
_ = db.Callback().Query().Before("gorm:query").Register(name, fn)
}
func TestArticleRepositoryWritesOnSQLite(t *testing.T) {
setup := func(t *testing.T) (*persistence.ArticleRepository, *gorm.DB, uint32) {
t.Helper()
db := openContractSQLite(t)
repo := persistence.NewArticleRepository(db)
defaultSite, err := persistence.NewSiteRepository(db).GetDefault()
if err != nil {
t.Fatal(err)
}
return repo, db, defaultSite.ID
}
t.Run("GetAll returns every stored article", func(t *testing.T) {
repo, _, siteID := setup(t)
for range 2 {
if _, err := repo.Create(articleOn(siteID)); err != nil {
t.Fatal(err)
}
}
if all := repo.GetAll(); len(all) != 2 {
t.Errorf("articles = %+v", all)
}
})
t.Run("GetAll returns an empty list when the query fails", func(t *testing.T) {
repo, db, _ := setup(t)
fault := injectDatabaseFault(db, "fault:getall", faultOnQuery)
fault.armed.Store(true)
all := repo.GetAll()
if all == nil || len(all) != 0 {
t.Errorf("articles = %#v", all)
}
})
t.Run("UpdateBody changes only the body", func(t *testing.T) {
repo, _, siteID := setup(t)
created, _ := repo.Create(articleOn(siteID))
updated, err := repo.UpdateBody(created.ID, "New body")
reread, _ := repo.Get(created.ID)
if err != nil || updated.Body != "New body" || updated.Title != "Article" || reread.Body != "New body" {
t.Errorf("updated %+v, reread %+v, err %v", updated, reread, err)
}
})
t.Run("Update applies each provided field", func(t *testing.T) {
repo, db, siteID := setup(t)
other, err := persistence.NewSiteRepository(db).Create(site.Site{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index"})
if err != nil {
t.Fatal(err)
}
created, _ := repo.Create(articleOn(siteID))
body := "New body"
bodied, err2 := repo.Update(created.ID, article.Update{Body: &body})
moved, err3 := repo.Update(created.ID, article.Update{SiteID: &other.ID})
untouched, err4 := repo.Update(created.ID, article.Update{})
if err2 != nil || err3 != nil || err4 != nil {
t.Fatalf("errors %v %v %v", err2, err3, err4)
}
if bodied.Title != "Article" || bodied.Body != body || bodied.SiteID != siteID {
t.Errorf("bodied = %+v", bodied)
}
if moved.SiteID != other.ID || moved.Title != "Article" || moved.Body != body {
t.Errorf("moved = %+v", moved)
}
if untouched.Title != moved.Title || untouched.Body != moved.Body || untouched.SiteID != moved.SiteID {
t.Errorf("untouched = %+v, expected %+v", untouched, moved)
}
})
t.Run("Missing articles are reported as not found", func(t *testing.T) {
repo, _, _ := setup(t)
body := "Body"
_, errBody := repo.UpdateBody(99, "x")
_, errUpdate := repo.Update(99, article.Update{Body: &body})
_, errOnline := repo.UpdateIsOnline(99, true)
for name, err := range map[string]error{"UpdateBody": errBody, "Update": errUpdate, "UpdateIsOnline": errOnline} {
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("%s: %v", name, err)
}
}
})
t.Run("A failing save surfaces the database error", func(t *testing.T) {
repo, db, siteID := setup(t)
created, _ := repo.Create(articleOn(siteID))
fault := injectDatabaseFault(db, "fault:save", faultOnUpdate)
fault.armed.Store(true)
body := "Body"
_, errBody := repo.UpdateBody(created.ID, "x")
_, errUpdate := repo.Update(created.ID, article.Update{Body: &body})
_, errOnline := repo.UpdateIsOnline(created.ID, true)
for name, err := range map[string]error{"UpdateBody": errBody, "Update": errUpdate, "UpdateIsOnline": errOnline} {
if !errors.Is(err, errInjectedDatabaseFailure) {
t.Errorf("%s: %v", name, err)
}
}
})
}
func TestSiteRepositoryLookupsAndImages(t *testing.T) {
t.Run("GetByKey finds a site and reports a missing one", func(t *testing.T) {
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
created := mustCreateSite(t, sites, "Shop", "shop", false)
found, err := sites.GetByKey("shop")
_, missing := sites.GetByKey("nope")
if err != nil || found.ID != created.ID || !errors.Is(missing, site.ErrSiteNotFound) {
t.Errorf("found %+v, err %v, missing %v", found, err, missing)
}
})
t.Run("AssignImage sets the image on the listed sites only", func(t *testing.T) {
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
first := mustCreateSite(t, sites, "First", "first", false)
second := mustCreateSite(t, sites, "Second", "second", false)
err := sites.AssignImage([]uint32{first.ID}, 7)
emptyErr := sites.AssignImage(nil, 8)
assigned, _ := sites.Get(first.ID)
other, _ := sites.Get(second.ID)
if err != nil || emptyErr != nil || assigned.DefaultSocialImageID == nil || *assigned.DefaultSocialImageID != 7 || other.DefaultSocialImageID != nil {
t.Errorf("assigned %+v, other %+v, errs %v %v", assigned, other, err, emptyErr)
}
})
}
func TestSiteSeedReportsLookupFailure(t *testing.T) {
db := openContractSQLite(t)
_ = db.Callback().Query().Before("gorm:query").Register("fault:seed", func(tx *gorm.DB) {
if _, ordered := tx.Statement.Clauses["ORDER BY"]; ordered {
_ = tx.AddError(errInjectedDatabaseFailure)
}
})
err := database.SeedDefaultSite(db, migrationPublicURL)
if !errors.Is(err, errInjectedDatabaseFailure) {
t.Errorf("err = %v", err)
}
}
func TestSiteHandlerLogsResponseWriteFailures(t *testing.T) {
capture := func(t *testing.T) *bytes.Buffer {
t.Helper()
var out bytes.Buffer
previous := log.Writer()
log.SetOutput(&out)
t.Cleanup(func() { log.SetOutput(previous) })
return &out
}
serve := func(e *siteEnv, w http.ResponseWriter, method string, path string) {
c := e.container
handler := siteHandler.NewHandler(c.ListSitesUseCase, c.CreateSiteUseCase, c.UpdateSiteUseCase, c.SetDefaultSiteUseCase, c.DeleteSiteUseCase)
router := chi.NewRouter()
router.Get("/", handler.List)
router.Patch("/{id}/default", handler.SetDefault)
router.ServeHTTP(w, httptest.NewRequest(method, path, nil))
}
t.Run("A failed list write is logged", func(t *testing.T) {
e := newSiteEnvWithShop(t)
out := capture(t)
serve(e, &erroringResponseWriter{}, http.MethodGet, "/")
if !strings.Contains(out.String(), "Failed to write response") {
t.Errorf("log = %q", out.String())
}
})
t.Run("A failed single site write is logged", func(t *testing.T) {
e := newSiteEnvWithShop(t)
out := capture(t)
serve(e, &erroringResponseWriter{}, http.MethodPatch, "/2/default")
if !strings.Contains(out.String(), "Failed to write response") {
t.Errorf("log = %q", out.String())
}
})
t.Run("Successful writes log nothing", func(t *testing.T) {
e := newSiteEnvWithShop(t)
out := capture(t)
serve(e, httptest.NewRecorder(), http.MethodGet, "/")
serve(e, httptest.NewRecorder(), http.MethodPatch, "/2/default")
if out.Len() != 0 {
t.Errorf("log = %q", out.String())
}
})
}
func TestSiteDeletionIsCheckedInsideTheTransaction(t *testing.T) {
setup := func(t *testing.T) (*persistence.SiteRepository, *persistence.ArticleRepository, site.Site, site.Site) {
t.Helper()
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
articles := persistence.NewArticleRepository(db)
shop := mustCreateSite(t, sites, "Shop", "shop", false)
blog := mustCreateSite(t, sites, "Blog", "blog", false)
return sites, articles, shop, blog
}
t.Run("A site that gained articles is not deleted without a destination", func(t *testing.T) {
sites, articles, shop, _ := setup(t)
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
t.Fatal(err)
}
err := sites.DeleteMovingArticles(shop.ID, nil)
if _, getErr := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteHasArticles) || getErr != nil {
t.Errorf("err = %v, get = %v", err, getErr)
}
})
t.Run("A missing destination changes nothing", func(t *testing.T) {
sites, articles, shop, _ := setup(t)
created, _ := articles.Create(articleOn(shop.ID))
missing := uint32(999)
err := sites.DeleteMovingArticles(shop.ID, &missing)
moved, _ := articles.Get(created.ID)
if _, getErr := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteNotFound) || getErr != nil || moved.SiteID != shop.ID {
t.Errorf("err = %v, get = %v, article site = %d", err, getErr, moved.SiteID)
}
})
t.Run("The default site is never deleted", func(t *testing.T) {
sites, _, _, _ := setup(t)
defaultSite, _ := sites.GetDefault()
err := sites.DeleteMovingArticles(defaultSite.ID, nil)
if !errors.Is(err, site.ErrDefaultSite) {
t.Errorf("err = %v", err)
}
})
t.Run("Articles move to the destination and the site goes away", func(t *testing.T) {
sites, articles, shop, blog := setup(t)
created, _ := articles.Create(articleOn(shop.ID))
err := sites.DeleteMovingArticles(shop.ID, &blog.ID)
moved, _ := articles.Get(created.ID)
_, getErr := sites.Get(shop.ID)
if err != nil || moved.SiteID != blog.ID || !errors.Is(getErr, site.ErrSiteNotFound) {
t.Errorf("err = %v, article site = %d, get = %v", err, moved.SiteID, getErr)
}
})
t.Run("An empty site is deleted without a destination", func(t *testing.T) {
sites, _, shop, _ := setup(t)
err := sites.DeleteMovingArticles(shop.ID, nil)
if _, getErr := sites.Get(shop.ID); err != nil || !errors.Is(getErr, site.ErrSiteNotFound) {
t.Errorf("err = %v, get = %v", err, getErr)
}
})
t.Run("Writing an article to a deleted site is refused", func(t *testing.T) {
sites, articles, shop, blog := setup(t)
created, _ := articles.Create(articleOn(blog.ID))
if err := sites.DeleteMovingArticles(shop.ID, nil); err != nil {
t.Fatal(err)
}
_, createErr := articles.Create(articleOn(shop.ID))
_, updateErr := articles.Update(created.ID, article.Update{SiteID: &shop.ID})
unchanged, _ := articles.Get(created.ID)
if !errors.Is(createErr, site.ErrSiteNotFound) || !errors.Is(updateErr, site.ErrSiteNotFound) || unchanged.SiteID != blog.ID || len(articles.GetAll()) != 1 {
t.Errorf("create %v, update %v, site %d", createErr, updateErr, unchanged.SiteID)
}
})
}
func TestSiteNameAndBaseURLRules(t *testing.T) {
t.Run("Names are 1 to 100 characters", func(t *testing.T) {
cases := map[string]bool{"": false, "a": true, strings.Repeat("a", 100): true, strings.Repeat("a", 101): false, strings.Repeat("é", 100): true}
for name, valid := range cases {
if site.IsValidName(name) != valid {
t.Errorf("%d runes: expected %v", len([]rune(name)), valid)
}
}
})
t.Run("The seed and the normalizer share the origin rules", func(t *testing.T) {
for _, raw := range []string{"", "ftp://example.com", "https://", "://bad", "example.com"} {
_, seedErr := site.SeedBaseURL(raw)
_, normalizeErr := site.NormalizeBaseURL(raw)
if !errors.Is(seedErr, site.ErrInvalidSiteURL) || !errors.Is(normalizeErr, site.ErrInvalidSiteURL) {
t.Errorf("%q: seed %v, normalize %v", raw, seedErr, normalizeErr)
}
}
seeded, err := site.SeedBaseURL("HTTP://Example.COM/blog?x=1")
if err != nil || seeded != "https://example.com" {
t.Errorf("seeded = %q, %v", seeded, err)
}
if _, err := site.NormalizeBaseURL("http://example.com"); !errors.Is(err, site.ErrInvalidSiteURL) {
t.Errorf("err = %v", err)
}
})
}
+165
View File
@@ -0,0 +1,165 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"strings"
"testing"
)
func TestSiteDomainRules(t *testing.T) {
t.Run("NormalizeBaseURL accepts what the rule allows and strips the trailing slash", func(t *testing.T) {
accepted := map[string]string{
"https://shop.example.com": "https://shop.example.com",
"https://shop.example.com/": "https://shop.example.com",
"https://SHOP.example.com:8443": "https://shop.example.com:8443",
"http://localhost": "http://localhost",
"http://[::1]:8080/": "http://[::1]:8080",
"HTTPS://shop.example.com": "https://shop.example.com",
}
for sent, stored := range accepted {
if got, err := site.NormalizeBaseURL(sent); err != nil || got != stored {
t.Errorf("%q gave %q, %v; expected %q", sent, got, err, stored)
}
}
})
t.Run("NormalizeBaseURL refuses everything else", func(t *testing.T) {
for _, sent := range []string{"", "http://shop.example.com", "https://", "https://shop.example.com/x", "https://shop.example.com?x", "https://shop.example.com/#top", "https://u:p@shop.example.com", "ftp://shop.example.com", "shop.example.com", "localhost:8080", "https://shop example.com"} {
if got, err := site.NormalizeBaseURL(sent); !errors.Is(err, site.ErrInvalidSiteURL) {
t.Errorf("%q gave %q, %v; expected ErrInvalidSiteURL", sent, got, err)
}
}
})
t.Run("SeedBaseURL drops the path and upgrades non-loopback http", func(t *testing.T) {
accepted := map[string]string{
"https://www.example.com/blog": "https://www.example.com",
"http://www.example.com": "https://www.example.com",
"http://localhost:5173": "http://localhost:5173",
"http://[::1]:8080/x": "http://[::1]:8080",
"https://user@www.example.com": "https://www.example.com",
}
for sent, stored := range accepted {
if got, err := site.SeedBaseURL(sent); err != nil || got != stored {
t.Errorf("%q gave %q, %v; expected %q", sent, got, err, stored)
}
}
for _, sent := range []string{"", "://not a url", "ftp://example.com", "example.com"} {
if _, err := site.SeedBaseURL(sent); err == nil {
t.Errorf("%q must be refused", sent)
}
}
})
t.Run("NormalizeLocale keeps valid tags and turns empty into none", func(t *testing.T) {
for _, locale := range []string{"en", "fr-CA", "zh-Hant-TW", "yue"} {
if got, err := site.NormalizeLocale(&locale); err != nil || got == nil || *got != locale {
t.Errorf("%q gave %v, %v", locale, got, err)
}
}
empty := ""
if got, err := site.NormalizeLocale(&empty); err != nil || got != nil {
t.Errorf("empty gave %v, %v", got, err)
}
if got, err := site.NormalizeLocale(nil); err != nil || got != nil {
t.Errorf("nil gave %v, %v", got, err)
}
for _, locale := range []string{"english", "fr_FR", "f", "FR", "fr-"} {
if _, err := site.NormalizeLocale(&locale); !errors.Is(err, site.ErrInvalidLocale) {
t.Errorf("%q must be refused", locale)
}
}
})
t.Run("IsValidKey enforces the pattern and the length", func(t *testing.T) {
valid := []string{"ab", "shop", "my-shop-2", "a1", strings.Repeat("a", 32)}
invalid := []string{"", "a", "Shop", "-shop", "shop-", "my--shop", "my_shop", strings.Repeat("a", 33)}
for _, key := range valid {
if !site.IsValidKey(key) {
t.Errorf("%q must be valid", key)
}
}
for _, key := range invalid {
if site.IsValidKey(key) {
t.Errorf("%q must be invalid", key)
}
}
})
}
func TestSiteUseCaseRules(t *testing.T) {
t.Run("Creating an article assigns it to the default site", func(t *testing.T) {
repo := newMockArticleRepo()
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()).CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
if err != nil || created.SiteID != 1 {
t.Errorf("created %+v, %v", created, err)
}
})
t.Run("Creating an article fails when no default site can be read", func(t *testing.T) {
repo := newMockArticleRepo()
sites := newMockSiteRepo(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites)
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
if !errors.Is(err, site.ErrSiteNotFound) || repo.createCalled {
t.Errorf("err = %v, create called = %v", err, repo.createCalled)
}
})
t.Run("A destination site that does not exist leaves the article untouched", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
sites := newMockSiteRepo(repo)
sites.seed(1, "Main", "main", true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites)
missing := uint32(9)
_, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing})
if !errors.Is(err, site.ErrSiteNotFound) || repo.articles[1].SiteID != 1 {
t.Errorf("err = %v, stored %+v", err, repo.articles[1])
}
})
t.Run("Deleting a site validates the destination before touching anything", func(t *testing.T) {
repo := newMockArticleRepo()
sites := newMockSiteRepo(repo)
sites.seed(1, "Main", "main", true)
sites.seed(2, "Shop", "shop", false)
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 2}
uc := useCases.NewDeleteSiteUseCase(sites, discardRecorder)
self := uint32(2)
for name, command := range map[string]useCases.DeleteSiteCommand{
"itself": {ID: 2, ReassignTo: &self},
"invalid": {ID: 2, ReassignInvalid: true},
"zero": {ID: 2, ReassignTo: idPointer(0)},
} {
if err := uc.Delete(testCtx, command); !errors.Is(err, site.ErrInvalidReassign) {
t.Errorf("%s: err = %v", name, err)
}
}
if sites.deleteCalls != 0 {
t.Errorf("the repository was called %d times", sites.deleteCalls)
}
})
t.Run("Editing a site with is_default is refused", func(t *testing.T) {
repo := newMockArticleRepo()
sites := newMockSiteRepo(repo)
sites.seed(2, "Shop", "shop", false)
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder)
flag := true
_, err := uc.Update(testCtx, 2, site.Patch{IsDefault: &flag})
if !errors.Is(err, site.ErrUseSetDefault) {
t.Errorf("err = %v", err)
}
})
}
+140
View File
@@ -0,0 +1,140 @@
package test
import (
"RenewCMS/internal/domain/article"
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
type mediaListBody struct {
Items []struct {
ID uint32 `json:"id"`
UsedIn int `json:"used_in"`
} `json:"items"`
Counts struct {
Unused int `json:"unused"`
} `json:"counts"`
}
func (e *siteEnv) useImageOnSite(siteID uint32, imageID uint32) {
s := e.sites.sites[siteID]
s.DefaultSocialImageID = idPointer(imageID)
e.sites.sites[siteID] = s
}
func (e *siteEnv) seedDuplicates() {
e.seedBanner(5, "banner.png", "samehash")
e.seedBanner(6, "banner-copy.png", "samehash")
}
func TestSitesMediaUsage(t *testing.T) {
t.Run("An image used as a default social image is counted as used", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
e.useImageOnSite(siteShopID, 5)
rec := e.owner(t, http.MethodGet, "/v1/media/", "")
unused := e.owner(t, http.MethodGet, "/v1/media/?filter=unused", "")
var list, unusedList mediaListBody
_ = json.Unmarshal(rec.Body.Bytes(), &list)
_ = json.Unmarshal(unused.Body.Bytes(), &unusedList)
if len(list.Items) != 1 || list.Items[0].UsedIn != 1 || list.Counts.Unused != 0 || len(unusedList.Items) != 0 {
t.Errorf("list %s, unused %s", rec.Body.String(), unused.Body.String())
}
})
t.Run("Deleting the image clears the site's field", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
e.useImageOnSite(siteShopID, 5)
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusNoContent || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
t.Errorf("status %d, image %v", rec.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
}
})
t.Run("A failed image deletion keeps the site's field", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
e.useImageOnSite(siteShopID, 5)
e.images.failDeleteFor = map[uint32]error{5: errRepositoryFailure}
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("status %d, image %v", rec.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
}
})
t.Run("Merging copies repoints the site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedDuplicates()
e.useImageOnSite(siteShopID, 6)
rec := e.owner(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 5, "merge_ids": [6]}`)
var body map[string]int
_ = json.Unmarshal(rec.Body.Bytes(), &body)
kept := e.sites.sites[siteShopID].DefaultSocialImageID
if rec.Code != http.StatusOK || body["sites_updated"] != 1 || kept == nil || *kept != 5 {
t.Errorf("status %d, body %s, image %v", rec.Code, rec.Body.String(), kept)
}
})
t.Run("Purge repoints sites the same way", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedDuplicates()
e.articles.articles[1] = articleUsingImage(5)
e.useImageOnSite(siteShopID, 6)
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
var body map[string]int
_ = json.Unmarshal(rec.Body.Bytes(), &body)
kept := e.sites.sites[siteShopID].DefaultSocialImageID
if rec.Code != http.StatusOK || body["sites_updated"] != 1 || kept == nil || *kept != 5 {
t.Errorf("status %d, body %s, image %v", rec.Code, rec.Body.String(), kept)
}
})
t.Run("A failed purge group leaves the site pointing at an existing image", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedDuplicates()
e.articles.articles[1] = articleUsingImage(5)
e.useImageOnSite(siteShopID, 6)
e.images.failDeleteFor = map[uint32]error{6: errRepositoryFailure}
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
referenced := e.sites.sites[siteShopID].DefaultSocialImageID
if rec.Code != http.StatusOK || referenced == nil {
t.Fatalf("status %d, image %v", rec.Code, referenced)
}
if _, exists := e.images.images[*referenced]; !exists {
t.Errorf("the site points at the missing image %d", *referenced)
}
})
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
e.useImageOnSite(siteShopID, 5)
e.articles.articles[1] = articleUsingImage(5)
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "site") {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
}
func articleUsingImage(imageID uint32) article.Article {
return article.Article{ID: 1, Title: "Spring release", Body: `<img src="` + producersBannerPath + strconv.Itoa(int(imageID)) + `.png">`, IsOnline: true, SiteID: siteMainID}
}
+359
View File
@@ -0,0 +1,359 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"path/filepath"
"strings"
"testing"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func openUnmigratedSQLite(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "sites.db"))
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: path})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
connection.DB.Logger = logger.Discard
t.Cleanup(func() { _ = connection.Close() })
return connection.DB
}
func storedSites(t *testing.T, db *gorm.DB) []entity.Site {
t.Helper()
var sites []entity.Site
if err := db.Order("id ASC").Find(&sites).Error; err != nil {
t.Fatalf("read sites: %v", err)
}
return sites
}
func TestSiteSeedAndMigration(t *testing.T) {
t.Run("A fresh database gets one default site from PUBLIC_URL", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, "https://www.example.com/blog"); err != nil {
t.Fatalf("migrate: %v", err)
}
sites := storedSites(t, db)
if len(sites) != 1 {
t.Fatalf("sites = %+v", sites)
}
seeded := sites[0]
if seeded.Name != "Default" || seeded.Key != "default" || seeded.BaseURL != "https://www.example.com" || seeded.DefaultRobots != "index" || seeded.Locale != nil || seeded.DefaultSocialImageID != nil || !seeded.IsDefault {
t.Errorf("seeded = %+v", seeded)
}
})
t.Run("The seed follows the base URL rule", func(t *testing.T) {
cases := [][2]string{
{"https://www.example.com/", "https://www.example.com"},
{"https://www.example.com/a/b", "https://www.example.com"},
{"http://www.example.com", "https://www.example.com"},
{"http://localhost:8080", "http://localhost:8080"},
{"http://127.0.0.1:8080/x", "http://127.0.0.1:8080"},
}
for _, c := range cases {
t.Run(c[0], func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, c[0]); err != nil {
t.Fatalf("migrate: %v", err)
}
if got := storedSites(t, db)[0].BaseURL; got != c[1] {
t.Errorf("base_url = %q, expected %q", got, c[1])
}
})
}
})
t.Run("An unparseable PUBLIC_URL stops the migration", func(t *testing.T) {
db := openUnmigratedSQLite(t)
err := database.Migrate(db, "://not a url")
if err == nil || !strings.Contains(err.Error(), "PUBLIC_URL") {
t.Fatalf("expected an error naming PUBLIC_URL, got %v", err)
}
if sites := storedSites(t, db); len(sites) != 0 {
t.Errorf("sites were created: %+v", sites)
}
})
t.Run("Existing articles are backfilled", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&legacyArticle{}); err != nil {
t.Fatalf("legacy migrate: %v", err)
}
for index := 0; index < 4; index++ {
if err := db.Create(&legacyArticle{Title: "Article", Body: "Body"}).Error; err != nil {
t.Fatalf("seed article: %v", err)
}
}
if err := db.Delete(&legacyArticle{}, 4).Error; err != nil {
t.Fatalf("soft delete: %v", err)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
defaultSite := storedSites(t, db)[0]
var unassigned int64
db.Unscoped().Model(&entity.Article{}).Where("site_id <> ?", defaultSite.ID).Count(&unassigned)
var total int64
db.Unscoped().Model(&entity.Article{}).Where("site_id = ?", defaultSite.ID).Count(&total)
if unassigned != 0 || total != 4 {
t.Errorf("assigned %d, others %d", total, unassigned)
}
})
t.Run("The seed runs once", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
before := storedSites(t, db)
if err := database.Migrate(db, "https://other.example.org"); err != nil {
t.Fatalf("second migrate: %v", err)
}
after := storedSites(t, db)
if len(after) != 1 || after[0] != before[0] {
t.Errorf("before %+v, after %+v", before, after)
}
})
t.Run("PUBLIC_URL is not used afterwards", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Model(&entity.Site{}).Where("is_default = ?", true).Update("base_url", "https://owner.example.net").Error; err != nil {
t.Fatalf("edit: %v", err)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("restart migrate: %v", err)
}
if got := storedSites(t, db)[0].BaseURL; got != "https://owner.example.net" {
t.Errorf("base_url = %q", got)
}
})
}
func TestSiteRepositoryContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
cases := []struct {
name string
run func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository)
}{
{"the migration seeds one default site that reads back with its count", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
seeded, err := sites.GetDefault()
if err != nil || seeded.Key != "default" || !seeded.IsDefault || seeded.ArticleCount != 0 {
t.Fatalf("default = %+v, %v", seeded, err)
}
}},
{"list puts the default first then orders by name and counts articles", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
zeta := mustCreateSite(t, sites, "Zeta", "zeta", false)
alpha := mustCreateSite(t, sites, "alpha", "alpha", false)
articles := persistence.NewArticleRepository(db)
for range 2 {
if _, err := articles.Create(articleOn(alpha.ID)); err != nil {
t.Fatal(err)
}
}
listed, err := sites.List()
if err != nil || len(listed) != 3 || listed[0].Key != "default" || listed[1].ID != alpha.ID || listed[2].ID != zeta.ID {
t.Fatalf("listed = %+v, %v", listed, err)
}
if listed[1].ArticleCount != 2 || listed[2].ArticleCount != 0 {
t.Errorf("counts = %+v", listed)
}
}},
{"a duplicate key is refused", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
mustCreateSite(t, sites, "Shop", "shop", false)
if _, err := sites.Create(site.Site{Name: "Other", Key: "shop", BaseURL: "https://o.example.com", DefaultRobots: "index"}); err == nil {
t.Error("expected the duplicate key to be refused")
}
}},
{"creating a default site unflags the previous one", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
created := mustCreateSite(t, sites, "Shop", "shop", true)
defaultSite, err := sites.GetDefault()
if err != nil || defaultSite.ID != created.ID {
t.Fatalf("default = %+v, %v", defaultSite, err)
}
assertSingleDefault(t, db)
}},
{"set default swaps the flag and an unknown id is not found", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
shop := mustCreateSite(t, sites, "Shop", "shop", false)
swapped, err := sites.SetDefault(shop.ID)
_, missing := sites.SetDefault(9999)
if err != nil || !swapped.IsDefault || !errors.Is(missing, site.ErrSiteNotFound) {
t.Fatalf("swapped %+v %v, missing %v", swapped, err, missing)
}
assertSingleDefault(t, db)
}},
{"update changes the editable fields and clears nullable ones", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
shop := mustCreateSite(t, sites, "Shop", "shop", false)
shop.Name, shop.Locale = "Boutique", stringPointer("fr")
updated, err := sites.Update(shop)
shop.Locale = nil
cleared, clearErr := sites.Update(shop)
if err != nil || updated.Name != "Boutique" || updated.Locale == nil || *updated.Locale != "fr" {
t.Fatalf("updated = %+v, %v", updated, err)
}
if clearErr != nil || cleared.Locale != nil {
t.Errorf("cleared = %+v, %v", cleared, clearErr)
}
if _, err := sites.Update(site.Site{ID: 9999}); !errors.Is(err, site.ErrSiteNotFound) {
t.Errorf("expected not found, got %v", err)
}
}},
{"delete moves the articles and removes the site in one operation", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
shop := mustCreateSite(t, sites, "Shop", "shop", false)
defaultSite, _ := sites.GetDefault()
articles := persistence.NewArticleRepository(db)
for range 3 {
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
t.Fatal(err)
}
}
err := sites.DeleteMovingArticles(shop.ID, &defaultSite.ID)
moved, _ := sites.Get(defaultSite.ID)
if err != nil || moved.ArticleCount != 3 {
t.Fatalf("delete: %v, moved %+v", err, moved)
}
if _, err := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteNotFound) {
t.Errorf("site still readable: %v", err)
}
if err := sites.DeleteMovingArticles(shop.ID, nil); !errors.Is(err, site.ErrSiteNotFound) {
t.Errorf("expected not found, got %v", err)
}
}},
{"images are cleared and repointed", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
image := mustCreateImage(t, newContractRepositories(db), "a.png", "hash-a")
other := mustCreateImage(t, newContractRepositories(db), "b.png", "hash-a")
shop := mustCreateSite(t, sites, "Shop", "shop", false)
shop.DefaultSocialImageID = &other.ID
if _, err := sites.Update(shop); err != nil {
t.Fatal(err)
}
repointed, err := sites.RepointImage([]uint32{other.ID}, image.ID)
afterRepoint, _ := sites.Get(shop.ID)
cleared, clearErr := sites.ClearImage(image.ID)
afterClear, _ := sites.Get(shop.ID)
if err != nil || repointed != 1 || afterRepoint.DefaultSocialImageID == nil || *afterRepoint.DefaultSocialImageID != image.ID {
t.Fatalf("repoint: %d %v %+v", repointed, err, afterRepoint)
}
if clearErr != nil || len(cleared) != 1 || cleared[0] != shop.ID || afterClear.DefaultSocialImageID != nil {
t.Errorf("clear: %v %v %+v", cleared, clearErr, afterClear)
}
}},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := backend.open(t)
c.run(t, db, persistence.NewSiteRepository(db))
})
}
})
}
}
func TestSiteRepositoryIsAtomicOnSQLite(t *testing.T) {
t.Run("The swap is atomic", func(t *testing.T) {
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
shop := mustCreateSite(t, sites, "Shop", "shop", false)
failWhenFlagged := "CREATE TRIGGER refuse_flag BEFORE UPDATE OF is_default ON sites WHEN NEW.is_default = 1 BEGIN SELECT RAISE(ABORT, 'refused'); END"
if err := db.Exec(failWhenFlagged).Error; err != nil {
t.Fatal(err)
}
if _, err := sites.SetDefault(shop.ID); err == nil {
t.Fatal("expected the swap to fail")
}
defaultSite, err := sites.GetDefault()
if err != nil || defaultSite.Key != "default" {
t.Errorf("default = %+v, %v", defaultSite, err)
}
assertSingleDefault(t, db)
})
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
shop := mustCreateSite(t, sites, "Shop", "shop", false)
defaultSite, _ := sites.GetDefault()
articles := persistence.NewArticleRepository(db)
for range 2 {
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
t.Fatal(err)
}
}
if err := db.Exec("CREATE TRIGGER refuse_delete BEFORE DELETE ON sites BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
t.Fatal(err)
}
if err := sites.DeleteMovingArticles(shop.ID, &defaultSite.ID); err == nil {
t.Fatal("expected the delete to fail")
}
still, err := sites.Get(shop.ID)
if err != nil || still.ArticleCount != 2 {
t.Errorf("site %+v, %v", still, err)
}
})
}
func mustCreateSite(t *testing.T, sites *persistence.SiteRepository, name string, key string, isDefault bool) site.Site {
t.Helper()
created, err := sites.Create(site.Site{Name: name, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index", IsDefault: isDefault})
if err != nil {
t.Fatalf("create site %q: %v", key, err)
}
return created
}
func articleOn(siteID uint32) article.Article {
return article.Article{Title: "Article", Body: "Body", SiteID: siteID}
}
func assertSingleDefault(t *testing.T, db *gorm.DB) {
t.Helper()
var defaults int64
if err := db.Model(&entity.Site{}).Where("is_default = ?", true).Count(&defaults).Error; err != nil || defaults != 1 {
t.Errorf("expected exactly one default site, got %d (%v)", defaults, err)
}
}
+372
View File
@@ -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
}
+23 -2
View File
@@ -163,7 +163,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
seedLegacyRows(t, db)
before := takeSqliteSnapshot(t, db)
if err := database.Migrate(db); err != nil {
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
after := takeSqliteSnapshot(t, db)
@@ -173,17 +173,38 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
delete(after.schema, name)
}
}
for _, name := range []string{"articles", "idx_articles_site_id"} {
delete(before.schema, name)
delete(after.schema, name)
}
if !reflect.DeepEqual(before.schema, after.schema) {
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
}
assertSiteColumnAdded(t, db)
delete(after.rows["articles"][0], "site_id")
if !reflect.DeepEqual(before.rows, after.rows) {
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
}
})
}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper()
var columns []string
if err := db.Raw("SELECT name FROM pragma_table_info('articles') ORDER BY cid").Scan(&columns).Error; err != nil {
t.Fatalf("read article columns: %v", err)
}
if columns[len(columns)-1] != "site_id" {
t.Errorf("expected site_id to be the only added article column, got %v", columns)
}
var sitesTable int
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sites'").Scan(&sitesTable).Error; err != nil || sitesTable != 1 {
t.Errorf("expected the sites table to exist: %d %v", sitesTable, err)
}
}
func isNewTableObject(name string) bool {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path"} {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites"} {
if strings.HasPrefix(name, prefix) {
return true
}
+3 -3
View File
@@ -23,11 +23,11 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
+11 -11
View File
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
@@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
@@ -198,7 +198,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListArticlesPage unverified", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -208,7 +208,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListArticlesPage verified", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -219,7 +219,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
func TestListArticlesPageEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
if err != nil {
@@ -243,7 +243,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -255,7 +255,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
_, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -264,7 +264,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -276,7 +276,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
_, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -386,7 +386,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
err := uc.Delete(testCtx, 1)
if err != nil {
@@ -400,7 +400,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
err := uc.Delete(testCtx, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
+3 -3
View File
@@ -21,11 +21,11 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)
+3 -3
View File
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
getUserUseCase,
)