feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5365
-214
No files matched your search
@@ -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"}},
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,3 +31,5 @@ func (f *failSwitch) trigger(method string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const migrationPublicURL = "https://www.example.com"
|
||||
+1
-1
@@ -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{
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user