feat: Authors feature backend (and upgraded deprecated mods)
This commit is contained in:
1 parent
a11338094e
commit
b34a173d83
103 files changed
+6454
-240
No files matched your search
@@ -93,7 +93,7 @@ func openRealSqlite(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.User{}, &models.Invite{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -20,6 +19,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/transform"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -60,7 +60,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
listHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
@@ -262,7 +262,8 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("cookie does not decode: %v", err)
|
||||
}
|
||||
claims, _ := decoded.AsMap(context.Background())
|
||||
claims := map[string]interface{}{}
|
||||
_ = transform.AsMap(decoded, claims)
|
||||
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
|
||||
t.Errorf("unexpected claims %v", claims)
|
||||
}
|
||||
@@ -417,7 +418,8 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("cookie does not decode: %v", err)
|
||||
}
|
||||
claims, _ := decoded.AsMap(context.Background())
|
||||
claims := map[string]interface{}{}
|
||||
_ = transform.AsMap(decoded, claims)
|
||||
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
|
||||
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
|
||||
}
|
||||
|
||||
@@ -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", "sites", "system", "users"}},
|
||||
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "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"}},
|
||||
|
||||
@@ -73,6 +73,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
container := &api.UseCases{
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
|
||||
@@ -83,7 +84,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, plainSites()),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock, plainSites(), plainAuthors()),
|
||||
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, plainSites())
|
||||
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock, plainSites(), plainAuthors())
|
||||
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, plainSites())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites())
|
||||
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
|
||||
|
||||
_, 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, plainSites())
|
||||
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder, plainSites())
|
||||
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites(), plainAuthors())
|
||||
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder, plainSites(), plainAuthors())
|
||||
|
||||
finished := make(chan struct{})
|
||||
go 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, plainSites())
|
||||
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites(), plainAuthors())
|
||||
w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock)
|
||||
w.export = useCases.NewExportActivityUseCase(h.probe, h.clock)
|
||||
return w
|
||||
|
||||
@@ -100,17 +100,18 @@ 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, plainSites())
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors())
|
||||
purgeSettings := newMockPurgeSettingsRepo()
|
||||
restartJobs := &fakeJobStore{}
|
||||
env.container = &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
|
||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
|
||||
@@ -126,11 +127,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(), plainSites()),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites()),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors()),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites()),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites(), plainAuthors()),
|
||||
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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
+45
-7
@@ -2,6 +2,7 @@ package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"sort"
|
||||
@@ -20,14 +21,36 @@ type mockArticleRepo struct {
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
createCalled bool
|
||||
credits map[uint32][]uint32
|
||||
authors *mockAuthorRepo
|
||||
}
|
||||
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
return &mockArticleRepo{
|
||||
articles: make(map[uint32]article.Article),
|
||||
credits: make(map[uint32][]uint32),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) withAuthors(a article.Article) article.Article {
|
||||
ids := m.credits[a.ID]
|
||||
if len(ids) == 0 || m.authors == nil {
|
||||
return a
|
||||
}
|
||||
bylines := make([]author.Byline, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if found, ok := m.authors.authors[id]; ok {
|
||||
bylines = append(bylines, found.Byline())
|
||||
}
|
||||
}
|
||||
a.Authors = bylines
|
||||
return a
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) credit(articleID uint32, authorIDs ...uint32) {
|
||||
m.credits[articleID] = append([]uint32{}, authorIDs...)
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) sortedIds() []uint32 {
|
||||
ids := make([]uint32, 0, len(m.articles))
|
||||
for id := range m.articles {
|
||||
@@ -78,7 +101,11 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
||||
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
|
||||
start := min((filters.Page-1)*filters.Size, len(matching))
|
||||
end := min(start+filters.Size, len(matching))
|
||||
return article.PageResult{Items: matching[start:end], Total: len(matching)}, nil
|
||||
items := make([]article.Article, 0)
|
||||
for _, a := range matching[start:end] {
|
||||
items = append(items, m.withAuthors(a))
|
||||
}
|
||||
return article.PageResult{Items: items, Total: len(matching)}, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) {
|
||||
@@ -142,7 +169,7 @@ func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
|
||||
if !ok {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
}
|
||||
return a, nil
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) GetByName(name string) (article.Article, error) {
|
||||
@@ -174,7 +201,14 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
m.nextID++
|
||||
a.ID = m.nextID
|
||||
m.articles[a.ID] = a
|
||||
return a, nil
|
||||
if len(a.Authors) > 0 {
|
||||
ids := make([]uint32, 0, len(a.Authors))
|
||||
for _, byline := range a.Authors {
|
||||
ids = append(ids, byline.ID)
|
||||
}
|
||||
m.credits[a.ID] = ids
|
||||
}
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
|
||||
@@ -190,7 +224,7 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
|
||||
}
|
||||
a.Body = body
|
||||
m.articles[id] = a
|
||||
return a, nil
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
|
||||
@@ -210,8 +244,11 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
|
||||
if update.SiteID != nil {
|
||||
a.SiteID = *update.SiteID
|
||||
}
|
||||
if update.AuthorIDs != nil {
|
||||
m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
|
||||
}
|
||||
m.articles[id] = a
|
||||
return a, nil
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
|
||||
@@ -224,7 +261,7 @@ func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Arti
|
||||
}
|
||||
a.IsOnline = isOnline
|
||||
m.articles[id] = a
|
||||
return a, nil
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) Delete(id uint32) error {
|
||||
@@ -235,6 +272,7 @@ func (m *mockArticleRepo) Delete(id uint32) error {
|
||||
return article.ErrArticleNotFound
|
||||
}
|
||||
delete(m.articles, id)
|
||||
delete(m.credits, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -256,7 +294,7 @@ var _ article.Repository = &mockArticleRepo{}
|
||||
|
||||
func TestCreateArticle(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
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, plainSites())
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestAuthorSlugGeneration(t *testing.T) {
|
||||
cases := []struct{ name, slug string }{
|
||||
{"Camille Roche", "camille-roche"},
|
||||
{"Inès Duarte", "ines-duarte"},
|
||||
{"Zoë Œuvre Łukasz", "zoe-oeuvre-lukasz"},
|
||||
{"Straße Müller", "strasse-muller"},
|
||||
{"Dr. O'Neil Smith", "dr-o-neil-smith"},
|
||||
{" --Marta-- ", "marta"},
|
||||
{"!!!", "author"},
|
||||
{"", "author"},
|
||||
{strings.Repeat("ab ", 60), strings.TrimRight(strings.Repeat("ab-", 60)[:100], "-")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := author.GenerateSlug(c.name)
|
||||
if got != c.slug || !author.IsValidSlug(got) {
|
||||
t.Errorf("slug = %q, expected %q", got, c.slug)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The suffix always fits in 100 characters", func(t *testing.T) {
|
||||
base := strings.Repeat("a", 100)
|
||||
for _, attempt := range []int{2, 9, 10, 99, 100} {
|
||||
got := author.SlugWithSuffix(base, attempt)
|
||||
if utf8.RuneCountInString(got) > 100 || !author.IsValidSlug(got) || !strings.HasSuffix(got, "-"+itoaForTest(attempt)) {
|
||||
t.Errorf("attempt %d: %q", attempt, got)
|
||||
}
|
||||
}
|
||||
if author.SlugWithSuffix("marta", 1) != "marta" {
|
||||
t.Errorf("the first attempt must keep the base slug")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func itoaForTest(value int) string {
|
||||
digits := ""
|
||||
for value > 0 {
|
||||
digits = string(rune('0'+value%10)) + digits
|
||||
value /= 10
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
func TestAuthorValueRules(t *testing.T) {
|
||||
t.Run("Names are counted in runes after trimming", func(t *testing.T) {
|
||||
for name, valid := range map[string]bool{"": false, " ": false, "a": true, strings.Repeat("é", 100): true, strings.Repeat("é", 101): false, " a ": true} {
|
||||
if author.IsValidName(name) != valid {
|
||||
t.Errorf("%q valid = %v", name, !valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The bio is counted in runes", func(t *testing.T) {
|
||||
if author.ValidateBio(strings.Repeat("é", 280)) != nil || !errors.Is(author.ValidateBio(strings.Repeat("é", 281)), author.ErrBioTooLong) {
|
||||
t.Errorf("bio rule broken")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("URLs must be https with a host", func(t *testing.T) {
|
||||
for raw, valid := range map[string]bool{"https://a.example": true, "http://a.example": false, "a.example": false, "https://": false, "": false, "https://a.example/" + strings.Repeat("a", 2048): false} {
|
||||
if (author.ValidateURL(raw) == nil) != valid {
|
||||
t.Errorf("%q valid = %v", raw, !valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty website means none", func(t *testing.T) {
|
||||
empty := ""
|
||||
if got, err := author.NormalizeWebsite(&empty); got != nil || err != nil {
|
||||
t.Errorf("got %v %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("At most five links", func(t *testing.T) {
|
||||
link := author.SocialLink{Type: "x", URL: "https://x.com/a"}
|
||||
if author.ValidateLinks([]author.SocialLink{link, link, link, link, link}) != nil || !errors.Is(author.ValidateLinks([]author.SocialLink{link, link, link, link, link, link}), author.ErrTooManyLinks) {
|
||||
t.Errorf("link count rule broken")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorUseCases(t *testing.T) {
|
||||
t.Run("The own author falls back to later slugs when the repository reports a race", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
users.users[1] = userNamed(1, "marta", "Marta Alves")
|
||||
authors := plainAuthors()
|
||||
authors.fail.failNextWith("Create", author.ErrSlugTaken)
|
||||
uc := useCases.NewCreateMyAuthorUseCase(authors, users, discardRecorder)
|
||||
|
||||
created, err := uc.Create(context.Background(), 1)
|
||||
|
||||
if err != nil || created.Slug != "marta-alves-2" {
|
||||
t.Errorf("created %+v %v", created, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting an author without articles does not need a destination", func(t *testing.T) {
|
||||
authors := plainAuthors()
|
||||
authors.seed(2, "B", "b")
|
||||
uc := useCases.NewDeleteAuthorUseCase(authors, discardRecorder)
|
||||
|
||||
err := uc.Delete(context.Background(), 2, useCases.Reassignment{Mode: useCases.ReassignMalformed})
|
||||
|
||||
if err != nil || len(authors.deleteCalls) != 1 || authors.deleteCalls[0].reassignTo != nil {
|
||||
t.Errorf("err %v calls %+v", err, authors.deleteCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A caller without author rights cannot touch another author", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
authors := plainAuthors()
|
||||
authors.seed(2, "B", "b")
|
||||
uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
|
||||
bio := "x"
|
||||
|
||||
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Role: "user"}, author.Patch{Bio: &bio})
|
||||
|
||||
if !errors.Is(err, author.ErrAuthorRights) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorDomainBoundaries(t *testing.T) {
|
||||
t.Run("A base slug that exactly fits is kept as is before the suffix", func(t *testing.T) {
|
||||
base := strings.Repeat("a", 97) + "-"
|
||||
|
||||
got := author.SlugWithSuffix(base, 2)
|
||||
|
||||
if got != base+"-2" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A url of exactly the maximum length is accepted and one more is refused", func(t *testing.T) {
|
||||
prefix := "https://example.com/"
|
||||
exact := prefix + strings.Repeat("a", author.MaxURLLength-len(prefix))
|
||||
|
||||
if err := author.ValidateURL(exact); err != nil {
|
||||
t.Errorf("exact length refused: %v", err)
|
||||
}
|
||||
if err := author.ValidateURL(exact + "a"); !errors.Is(err, author.ErrInvalidURL) {
|
||||
t.Errorf("over length accepted: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A byline always carries a links array", func(t *testing.T) {
|
||||
bare := author.Author{ID: 1, Name: "A", Slug: "a"}.Byline()
|
||||
kept := author.Author{ID: 1, SocialLinks: []author.SocialLink{{Type: "x", URL: "https://x.com/a"}}}.Byline()
|
||||
|
||||
if bare.SocialLinks == nil || len(bare.SocialLinks) != 0 || len(kept.SocialLinks) != 1 {
|
||||
t.Errorf("bare %+v kept %+v", bare, kept)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type recordingAuthorRepo struct {
|
||||
*mockAuthorRepo
|
||||
created []author.Author
|
||||
}
|
||||
|
||||
func (r *recordingAuthorRepo) Create(a author.Author) (author.Author, error) {
|
||||
a.ID = uint32(len(r.created) + 100)
|
||||
r.created = append(r.created, a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func TestAuthorUseCaseGuards(t *testing.T) {
|
||||
t.Run("A taken slug is rejected by the use case itself", func(t *testing.T) {
|
||||
authors := &recordingAuthorRepo{mockAuthorRepo: plainAuthors()}
|
||||
authors.seed(1, "A", "taken")
|
||||
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
|
||||
|
||||
_, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "B", Slug: "taken"})
|
||||
|
||||
if !errors.Is(err, author.ErrSlugTaken) || len(authors.created) != 0 {
|
||||
t.Errorf("err %v created %d", err, len(authors.created))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The linked flag follows the user id on creation", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
users.users[7] = userNamed(7, "u7", "U Seven")
|
||||
authors := &recordingAuthorRepo{mockAuthorRepo: plainAuthors()}
|
||||
uc := useCases.NewCreateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
|
||||
userID := uint32(7)
|
||||
|
||||
_, linkedErr := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Linked", UserID: &userID})
|
||||
_, freeErr := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Free"})
|
||||
|
||||
if linkedErr != nil || freeErr != nil || len(authors.created) != 2 {
|
||||
t.Fatalf("errs %v %v created %d", linkedErr, freeErr, len(authors.created))
|
||||
}
|
||||
if !authors.created[0].IsLinked || authors.created[1].IsLinked {
|
||||
t.Errorf("flags %v %v", authors.created[0].IsLinked, authors.created[1].IsLinked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The own author can use the fiftieth slug candidate", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
users.users[1] = userNamed(1, "marta", "Marta Alves")
|
||||
authors := plainAuthors()
|
||||
base := author.GenerateSlug("Marta Alves")
|
||||
for attempt := 1; attempt < 50; attempt++ {
|
||||
authors.seed(uint32(attempt), "X", author.SlugWithSuffix(base, attempt))
|
||||
}
|
||||
uc := useCases.NewCreateMyAuthorUseCase(authors, users, discardRecorder)
|
||||
|
||||
created, err := uc.Create(context.Background(), 1)
|
||||
|
||||
if err != nil || created.Slug != author.SlugWithSuffix(base, 50) {
|
||||
t.Errorf("created %+v %v", created, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type staleCountAuthorRepo struct {
|
||||
*mockAuthorRepo
|
||||
}
|
||||
|
||||
func (s *staleCountAuthorRepo) Get(id uint32) (author.Author, error) {
|
||||
found, err := s.mockAuthorRepo.Get(id)
|
||||
found.ArticleCount = 0
|
||||
return found, err
|
||||
}
|
||||
|
||||
func TestAuthorDeleteRace(t *testing.T) {
|
||||
t.Run("A credit added after the count was read blocks the delete", func(t *testing.T) {
|
||||
base := plainAuthors()
|
||||
base.seed(2, "B", "b")
|
||||
base.articles.credits[10] = []uint32{2}
|
||||
uc := useCases.NewDeleteAuthorUseCase(&staleCountAuthorRepo{base}, discardRecorder)
|
||||
|
||||
err := uc.Delete(context.Background(), 2, useCases.Reassignment{Mode: useCases.ReassignAbsent})
|
||||
|
||||
if !errors.Is(err, author.ErrAuthorHasArticles) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
if _, exists := base.authors[2]; !exists {
|
||||
t.Errorf("author deleted despite credits")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Explicit removal uncredits the author", func(t *testing.T) {
|
||||
base := plainAuthors()
|
||||
base.seed(2, "B", "b")
|
||||
base.articles.credits[10] = []uint32{2, 3}
|
||||
uc := useCases.NewDeleteAuthorUseCase(base, discardRecorder)
|
||||
|
||||
err := uc.Delete(context.Background(), 2, useCases.Reassignment{Mode: useCases.ReassignNone})
|
||||
|
||||
if err != nil || len(base.articles.credits[10]) != 1 {
|
||||
t.Errorf("err %v credits %v", err, base.articles.credits[10])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorSocialTypes(t *testing.T) {
|
||||
for _, valid := range []string{author.SocialX, author.SocialLinkedIn, author.SocialGitHub, author.SocialMastodon, author.SocialCustom} {
|
||||
if !author.IsValidSocialType(valid) || author.ValidateLinks([]author.SocialLink{{Type: valid, URL: "https://a.example/x"}}) != nil {
|
||||
t.Errorf("%s refused", valid)
|
||||
}
|
||||
}
|
||||
err := author.ValidateLinks([]author.SocialLink{{Type: "facebook", URL: "https://a.example/x"}})
|
||||
if author.IsValidSocialType("") || !errors.Is(err, author.ErrInvalidSocialType) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func mustCreateAuthor(t *testing.T, authors *persistence.AuthorRepository, name string, slug string, userID *uint32) author.Author {
|
||||
t.Helper()
|
||||
created, err := authors.Create(author.Author{Name: name, Slug: slug, SocialLinks: []author.SocialLink{}, UserID: userID})
|
||||
if err != nil {
|
||||
t.Fatalf("create author %q: %v", slug, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func mustCreateCreditedArticle(t *testing.T, db *gorm.DB, title string, authorIDs ...uint32) article.Article {
|
||||
t.Helper()
|
||||
bylines := make([]author.Byline, 0, len(authorIDs))
|
||||
for _, id := range authorIDs {
|
||||
bylines = append(bylines, author.Byline{ID: id})
|
||||
}
|
||||
created, err := persistence.NewArticleRepository(db).Create(article.Article{Title: title, Body: "Body", Authors: bylines})
|
||||
if err != nil {
|
||||
t.Fatalf("create article: %v", err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func creditedIDs(t *testing.T, db *gorm.DB, articleID uint32) []uint32 {
|
||||
t.Helper()
|
||||
found, err := persistence.NewArticleRepository(db).Get(articleID)
|
||||
if err != nil {
|
||||
t.Fatalf("get article: %v", err)
|
||||
}
|
||||
ids := []uint32{}
|
||||
for _, byline := range found.Authors {
|
||||
ids = append(ids, byline.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestAuthorRepositoryContract(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, authors *persistence.AuthorRepository)
|
||||
}{
|
||||
{"create and read back every field", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
website := "https://example.com"
|
||||
created, err := authors.Create(author.Author{Name: "Camille", Slug: "camille", Bio: "Bio", Website: &website, SocialLinks: []author.SocialLink{{Type: "x", URL: "https://x.com/c"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := authors.Get(created.ID)
|
||||
|
||||
if err != nil || found.Name != "Camille" || found.Bio != "Bio" || *found.Website != website || len(found.SocialLinks) != 1 || found.IsLinked || found.ArticleCount != 0 {
|
||||
t.Errorf("found %+v %v", found, err)
|
||||
}
|
||||
bySlug, _ := authors.GetBySlug("camille")
|
||||
if bySlug.ID != created.ID {
|
||||
t.Errorf("by slug %+v", bySlug)
|
||||
}
|
||||
}},
|
||||
{"a missing author is a domain error", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
if _, err := authors.Get(99); !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
t.Errorf("get: %v", err)
|
||||
}
|
||||
if _, err := authors.GetBySlug("nope"); !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
t.Errorf("slug: %v", err)
|
||||
}
|
||||
if _, err := authors.GetByUserID(9); !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
t.Errorf("user: %v", err)
|
||||
}
|
||||
if err := authors.DeleteMovingCredits(99, nil); !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
t.Errorf("delete: %v", err)
|
||||
}
|
||||
}},
|
||||
{"slug and user are unique while many guests are allowed", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
user := uint32(1)
|
||||
mustCreateAuthor(t, authors, "A", "a", &user)
|
||||
mustCreateAuthor(t, authors, "B", "b", nil)
|
||||
mustCreateAuthor(t, authors, "C", "c", nil)
|
||||
|
||||
_, slugErr := authors.Create(author.Author{Name: "D", Slug: "a", SocialLinks: []author.SocialLink{}})
|
||||
_, userErr := authors.Create(author.Author{Name: "E", Slug: "e", SocialLinks: []author.SocialLink{}, UserID: &user})
|
||||
|
||||
if !errors.Is(slugErr, author.ErrSlugTaken) || !errors.Is(userErr, author.ErrUserAlreadyLinked) {
|
||||
t.Errorf("slug %v user %v", slugErr, userErr)
|
||||
}
|
||||
b, _ := authors.GetBySlug("b")
|
||||
b.Slug = "a"
|
||||
if _, err := authors.Update(b); !errors.Is(err, author.ErrSlugTaken) {
|
||||
t.Errorf("update: %v", err)
|
||||
}
|
||||
}},
|
||||
{"update changes every field and can clear the nullable ones", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
user := uint32(4)
|
||||
website := "https://example.com"
|
||||
created := mustCreateAuthor(t, authors, "A", "a", &user)
|
||||
created.Name = "Z"
|
||||
created.Website = &website
|
||||
created.UserID = nil
|
||||
created.SocialLinks = []author.SocialLink{{Type: "custom", URL: "https://c.example"}}
|
||||
|
||||
updated, err := authors.Update(created)
|
||||
|
||||
if err != nil || updated.Name != "Z" || updated.UserID != nil || updated.IsLinked || *updated.Website != website || len(updated.SocialLinks) != 1 {
|
||||
t.Errorf("updated %+v %v", updated, err)
|
||||
}
|
||||
updated.Website = nil
|
||||
cleared, _ := authors.Update(updated)
|
||||
if cleared.Website != nil {
|
||||
t.Errorf("cleared %+v", cleared)
|
||||
}
|
||||
}},
|
||||
{"the list is newest first, searchable and paginated", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
for i := 1; i <= 7; i++ {
|
||||
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
|
||||
}
|
||||
mustCreateAuthor(t, authors, "Inès Duarte", "zl", nil)
|
||||
|
||||
page, err := authors.List(author.ListQuery{Page: 2, Size: 5})
|
||||
search, _ := authors.List(author.ListQuery{Q: "INES", Page: 1, Size: 25})
|
||||
bySlug, _ := authors.List(author.ListQuery{Q: "zl", Page: 1, Size: 25})
|
||||
none, _ := authors.List(author.ListQuery{Q: "nobody", Page: 1, Size: 25})
|
||||
|
||||
if err != nil || page.Total != 8 || len(page.Items) != 3 || page.Items[0].Name != "Writer 3" {
|
||||
t.Errorf("page %+v %v", page, err)
|
||||
}
|
||||
if search.Total != 1 || bySlug.Total != 1 || none.Total != 0 || len(none.Items) != 0 {
|
||||
t.Errorf("search %+v slug %+v none %+v", search, bySlug, none)
|
||||
}
|
||||
}},
|
||||
{"credits keep their order and are counted", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
b := mustCreateAuthor(t, authors, "B", "b", nil)
|
||||
c := mustCreateAuthor(t, authors, "C", "c", nil)
|
||||
first := mustCreateCreditedArticle(t, db, "First", c.ID, a.ID, b.ID)
|
||||
mustCreateCreditedArticle(t, db, "Second", a.ID)
|
||||
|
||||
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{c.ID, a.ID, b.ID}) {
|
||||
t.Errorf("order = %v", got)
|
||||
}
|
||||
counted, _ := authors.Get(a.ID)
|
||||
if counted.ArticleCount != 2 {
|
||||
t.Errorf("count = %d", counted.ArticleCount)
|
||||
}
|
||||
reordered := []uint32{b.ID, c.ID}
|
||||
if _, err := persistence.NewArticleRepository(db).Update(first.ID, article.Update{AuthorIDs: &reordered}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, reordered) {
|
||||
t.Errorf("after replace = %v", got)
|
||||
}
|
||||
}},
|
||||
{"an author is credited at most once on an article", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
created := mustCreateCreditedArticle(t, db, "First", a.ID)
|
||||
duplicated := []uint32{a.ID, a.ID}
|
||||
|
||||
_, err := persistence.NewArticleRepository(db).Update(created.ID, article.Update{AuthorIDs: &duplicated})
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("expected the unique pair to be refused")
|
||||
}
|
||||
if got := creditedIDs(t, db, created.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
|
||||
t.Errorf("credits = %v", got)
|
||||
}
|
||||
}},
|
||||
{"deleting an article removes its credits and keeps the author", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
first := mustCreateCreditedArticle(t, db, "First", a.ID)
|
||||
mustCreateCreditedArticle(t, db, "Second", a.ID)
|
||||
|
||||
if err := persistence.NewArticleRepository(db).Delete(first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kept, _ := authors.Get(a.ID)
|
||||
var credits int64
|
||||
db.Model(&entity.ArticleAuthor{}).Where("article_id = ?", first.ID).Count(&credits)
|
||||
if kept.ArticleCount != 1 || credits != 0 {
|
||||
t.Errorf("count %d credits %d", kept.ArticleCount, credits)
|
||||
}
|
||||
}},
|
||||
{"reassigning keeps the position and never duplicates", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
b := mustCreateAuthor(t, authors, "B", "b", nil)
|
||||
c := mustCreateAuthor(t, authors, "C", "c", nil)
|
||||
d := mustCreateAuthor(t, authors, "D", "d", nil)
|
||||
positioned := mustCreateCreditedArticle(t, db, "Positioned", a.ID, b.ID, c.ID)
|
||||
shared := mustCreateCreditedArticle(t, db, "Shared", b.ID, d.ID)
|
||||
|
||||
if err := authors.DeleteMovingCredits(b.ID, &d.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := creditedIDs(t, db, positioned.ID); !reflect.DeepEqual(got, []uint32{a.ID, d.ID, c.ID}) {
|
||||
t.Errorf("positioned = %v", got)
|
||||
}
|
||||
if got := creditedIDs(t, db, shared.ID); !reflect.DeepEqual(got, []uint32{d.ID}) {
|
||||
t.Errorf("shared = %v", got)
|
||||
}
|
||||
if _, err := authors.Get(b.ID); !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
t.Errorf("the author still exists: %v", err)
|
||||
}
|
||||
}},
|
||||
{"deleting with no destination removes only that credit", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
b := mustCreateAuthor(t, authors, "B", "b", nil)
|
||||
c := mustCreateAuthor(t, authors, "C", "c", nil)
|
||||
shared := mustCreateCreditedArticle(t, db, "Shared", a.ID, b.ID, c.ID)
|
||||
alone := mustCreateCreditedArticle(t, db, "Alone", b.ID)
|
||||
|
||||
if err := authors.DeleteUncrediting(b.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := creditedIDs(t, db, shared.ID); !reflect.DeepEqual(got, []uint32{a.ID, c.ID}) {
|
||||
t.Errorf("shared = %v", got)
|
||||
}
|
||||
if got := creditedIDs(t, db, alone.ID); len(got) != 0 {
|
||||
t.Errorf("alone = %v", got)
|
||||
}
|
||||
}},
|
||||
{"avatars are counted, cleared and repointed", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
|
||||
images := newContractRepositories(db)
|
||||
kept := mustCreateImage(t, images, "a.png", "hash-a")
|
||||
copied := mustCreateImage(t, images, "b.png", "hash-a")
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
a.AvatarID = &copied.ID
|
||||
if _, err := authors.Update(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
usage, _ := authors.AvatarUsage()
|
||||
repointed, err := authors.RepointAvatar([]uint32{copied.ID}, kept.ID)
|
||||
afterRepoint, _ := authors.Get(a.ID)
|
||||
cleared, clearErr := authors.ClearAvatar(kept.ID)
|
||||
afterClear, _ := authors.Get(a.ID)
|
||||
restoreErr := authors.AssignAvatar(cleared, kept.ID)
|
||||
restored, _ := authors.Get(a.ID)
|
||||
|
||||
if usage[copied.ID] != 1 || err != nil || repointed != 1 || *afterRepoint.AvatarID != kept.ID {
|
||||
t.Fatalf("usage %v repoint %d %v %+v", usage, repointed, err, afterRepoint)
|
||||
}
|
||||
if clearErr != nil || len(cleared) != 1 || afterClear.AvatarID != nil || restoreErr != nil || *restored.AvatarID != kept.ID {
|
||||
t.Errorf("clear %v %v %+v restore %v %+v", cleared, clearErr, afterClear, restoreErr, restored)
|
||||
}
|
||||
}},
|
||||
}
|
||||
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.NewAuthorRepository(db))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorRepositoryIsAtomicOnSQLite(t *testing.T) {
|
||||
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
b := mustCreateAuthor(t, authors, "B", "b", nil)
|
||||
first := mustCreateCreditedArticle(t, db, "First", a.ID)
|
||||
if err := db.Exec("CREATE TRIGGER refuse_author_delete BEFORE DELETE ON authors BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := authors.DeleteMovingCredits(a.ID, &b.ID); err == nil {
|
||||
t.Fatal("expected the delete to fail")
|
||||
}
|
||||
|
||||
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
|
||||
t.Errorf("credits = %v", got)
|
||||
}
|
||||
if still, err := authors.Get(a.ID); err != nil || still.ArticleCount != 1 {
|
||||
t.Errorf("author %+v %v", still, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorRepositoryEdges(t *testing.T) {
|
||||
t.Run("A searched list pages through its matches and returns them", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
for i := 1; i <= 5; i++ {
|
||||
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
|
||||
}
|
||||
mustCreateAuthor(t, authors, "Other", "other", nil)
|
||||
|
||||
first, _ := authors.List(author.ListQuery{Q: "writer", Page: 1, Size: 2})
|
||||
second, _ := authors.List(author.ListQuery{Q: "writer", Page: 2, Size: 2})
|
||||
beyond, _ := authors.List(author.ListQuery{Q: "writer", Page: 4, Size: 2})
|
||||
|
||||
if first.Total != 5 || len(first.Items) != 2 || first.Items[0].Name != "Writer 5" || first.Items[1].Name != "Writer 4" {
|
||||
t.Errorf("first %+v", first)
|
||||
}
|
||||
if second.Total != 5 || len(second.Items) != 2 || second.Items[0].Name != "Writer 3" || second.Items[1].Name != "Writer 2" {
|
||||
t.Errorf("second %+v", second)
|
||||
}
|
||||
if beyond.Total != 5 || len(beyond.Items) != 0 {
|
||||
t.Errorf("beyond %+v", beyond)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A write failure that is not a conflict is reported", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
if err := db.Exec("CREATE TRIGGER refuse_author_insert BEFORE INSERT ON authors BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := authors.Create(author.Author{Name: "A", Slug: "a", SocialLinks: []author.SocialLink{}})
|
||||
|
||||
if err == nil || errors.Is(err, author.ErrSlugTaken) || created.ID != 0 {
|
||||
t.Errorf("created %+v err %v", created, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting toward a missing destination changes nothing", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
first := mustCreateCreditedArticle(t, db, "First", a.ID)
|
||||
missing := uint32(999)
|
||||
|
||||
toMissing := authors.DeleteMovingCredits(a.ID, &missing)
|
||||
fromMissing := authors.DeleteMovingCredits(missing, nil)
|
||||
|
||||
if !errors.Is(toMissing, author.ErrAuthorNotFound) || !errors.Is(fromMissing, author.ErrAuthorNotFound) {
|
||||
t.Errorf("errors %v %v", toMissing, fromMissing)
|
||||
}
|
||||
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
|
||||
t.Errorf("credits = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorRepositoryPaging(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
for i := 1; i <= 5; i++ {
|
||||
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
|
||||
}
|
||||
for _, q := range []string{"", "writer"} {
|
||||
empty, emptyErr := authors.List(author.ListQuery{Q: q, Page: 1, Size: 0})
|
||||
if emptyErr != nil || len(empty.Items) != 0 || empty.Total != 5 {
|
||||
t.Errorf("q=%q size 0 %+v %v", q, empty, emptyErr)
|
||||
}
|
||||
second, _ := authors.List(author.ListQuery{Q: q, Page: 2, Size: 2})
|
||||
last, _ := authors.List(author.ListQuery{Q: q, Page: 3, Size: 2})
|
||||
beyond, _ := authors.List(author.ListQuery{Q: q, Page: 4, Size: 2})
|
||||
if len(second.Items) != 2 || second.Items[0].Name != "Writer 3" {
|
||||
t.Errorf("q=%q second %+v", q, second)
|
||||
}
|
||||
if len(last.Items) != 1 || last.Items[0].Name != "Writer 1" || last.Total != 5 {
|
||||
t.Errorf("q=%q last %+v", q, last)
|
||||
}
|
||||
if len(beyond.Items) != 0 || beyond.Total != 5 {
|
||||
t.Errorf("q=%q beyond %+v", q, beyond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorMigration(t *testing.T) {
|
||||
t.Run("The new tables are migrated and existing articles get no authors", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := db.AutoMigrate(&entity.Article{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := db.Create(&entity.Article{Title: fmt.Sprintf("Old %d", i), Body: "Body"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
|
||||
var authors, credits int64
|
||||
db.Model(&entity.Author{}).Count(&authors)
|
||||
db.Model(&entity.ArticleAuthor{}).Count(&credits)
|
||||
if authors != 0 || credits != 0 || !db.Migrator().HasTable("authors") || !db.Migrator().HasTable("article_authors") {
|
||||
t.Errorf("authors %d credits %d", authors, credits)
|
||||
}
|
||||
found, err := persistence.NewArticleRepository(db).FindPage(article.Filters{Page: 1, Size: 10})
|
||||
if err != nil || found.Total != 4 || len(found.Items[0].Authors) != 0 {
|
||||
t.Errorf("articles %+v %v", found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The unique indexes exist", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
for _, name := range []string{"idx_authors_slug", "idx_authors_user_id", "idx_article_authors_pair"} {
|
||||
table := "authors"
|
||||
if name == "idx_article_authors_pair" {
|
||||
table = "article_authors"
|
||||
}
|
||||
if !db.Migrator().HasIndex(table, name) {
|
||||
t.Errorf("missing index %s", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorRepositoryGuards(t *testing.T) {
|
||||
t.Run("Deleting without a destination refuses a credited author", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
first := mustCreateCreditedArticle(t, db, "First", a.ID)
|
||||
|
||||
err := authors.DeleteMovingCredits(a.ID, nil)
|
||||
|
||||
if !errors.Is(err, author.ErrAuthorHasArticles) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
|
||||
t.Errorf("credits = %v", got)
|
||||
}
|
||||
if _, err := authors.Get(a.ID); err != nil {
|
||||
t.Errorf("author gone: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting without a destination removes an uncredited author", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
a := mustCreateAuthor(t, authors, "A", "a", nil)
|
||||
|
||||
if err := authors.DeleteMovingCredits(a.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := authors.Get(a.ID); !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
t.Errorf("get = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A huge page number lists nothing and keeps the total", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
authors := persistence.NewAuthorRepository(db)
|
||||
for i := 1; i <= 3; i++ {
|
||||
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
|
||||
}
|
||||
huge := int(^uint(0) >> 1)
|
||||
|
||||
plain, plainErr := authors.List(author.ListQuery{Page: huge, Size: 100})
|
||||
searched, searchErr := authors.List(author.ListQuery{Q: "writer", Page: huge, Size: 100})
|
||||
|
||||
if plainErr != nil || plain.Total != 3 || len(plain.Items) != 0 {
|
||||
t.Errorf("plain %+v %v", plain, plainErr)
|
||||
}
|
||||
if searchErr != nil || searched.Total != 3 || len(searched.Items) != 0 {
|
||||
t.Errorf("searched %+v %v", searched, searchErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type authorDeleteCall struct {
|
||||
id uint32
|
||||
reassignTo *uint32
|
||||
}
|
||||
|
||||
type mockAuthorRepo struct {
|
||||
authors map[uint32]author.Author
|
||||
articles *mockArticleRepo
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
createCalls int
|
||||
deleteCalls []authorDeleteCall
|
||||
}
|
||||
|
||||
func newMockAuthorRepo(articles *mockArticleRepo) *mockAuthorRepo {
|
||||
repo := &mockAuthorRepo{authors: map[uint32]author.Author{}, articles: articles}
|
||||
articles.authors = repo
|
||||
return repo
|
||||
}
|
||||
|
||||
func plainAuthors() *mockAuthorRepo {
|
||||
return newMockAuthorRepo(newMockArticleRepo())
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) seed(id uint32, name string, slug string) author.Author {
|
||||
seeded := author.Author{ID: id, Name: name, Slug: slug, SocialLinks: []author.SocialLink{}}
|
||||
m.authors[id] = seeded
|
||||
if id > m.nextID {
|
||||
m.nextID = id
|
||||
}
|
||||
return seeded
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) seedLinked(id uint32, name string, slug string, userID uint32) author.Author {
|
||||
seeded := m.seed(id, name, slug)
|
||||
seeded.UserID = idPointer(userID)
|
||||
seeded.IsLinked = true
|
||||
m.authors[id] = seeded
|
||||
return seeded
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) update(id uint32, change func(*author.Author)) {
|
||||
found := m.authors[id]
|
||||
change(&found)
|
||||
m.authors[id] = found
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) withCount(a author.Author) author.Author {
|
||||
a.ArticleCount = 0
|
||||
for articleID, ids := range m.articles.credits {
|
||||
if _, exists := m.articles.articles[articleID]; !exists {
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == a.ID {
|
||||
a.ArticleCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
a.IsLinked = a.UserID != nil
|
||||
return a
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) snapshot() map[uint32]author.Author {
|
||||
copied := make(map[uint32]author.Author, len(m.authors))
|
||||
for id, a := range m.authors {
|
||||
copied[id] = m.withCount(a)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) sortedIDsDescending() []uint32 {
|
||||
ids := make([]uint32, 0, len(m.authors))
|
||||
for id := range m.authors {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) List(query author.ListQuery) (author.Page, error) {
|
||||
if err := m.fail.trigger("List"); err != nil {
|
||||
return author.Page{}, err
|
||||
}
|
||||
needle := foldArticleSearch(strings.TrimSpace(query.Q))
|
||||
matching := []author.Author{}
|
||||
for _, id := range m.sortedIDsDescending() {
|
||||
a := m.authors[id]
|
||||
if needle != "" && !strings.Contains(foldArticleSearch(a.Name), needle) && !strings.Contains(foldArticleSearch(a.Slug), needle) {
|
||||
continue
|
||||
}
|
||||
matching = append(matching, m.withCount(a))
|
||||
}
|
||||
start := len(matching)
|
||||
if query.Size > 0 && query.Page-1 <= len(matching)/query.Size {
|
||||
start = min((query.Page-1)*query.Size, len(matching))
|
||||
}
|
||||
end := min(start+query.Size, len(matching))
|
||||
return author.Page{Items: matching[start:end], Total: len(matching)}, nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) Get(id uint32) (author.Author, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
found, ok := m.authors[id]
|
||||
if !ok {
|
||||
return author.Author{}, author.ErrAuthorNotFound
|
||||
}
|
||||
return m.withCount(found), nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) GetBySlug(slug string) (author.Author, error) {
|
||||
if err := m.fail.trigger("GetBySlug"); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
for _, a := range m.authors {
|
||||
if a.Slug == slug {
|
||||
return m.withCount(a), nil
|
||||
}
|
||||
}
|
||||
return author.Author{}, author.ErrAuthorNotFound
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) GetByUserID(userID uint32) (author.Author, error) {
|
||||
if err := m.fail.trigger("GetByUserID"); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
for _, a := range m.authors {
|
||||
if a.UserID != nil && *a.UserID == userID {
|
||||
return m.withCount(a), nil
|
||||
}
|
||||
}
|
||||
return author.Author{}, author.ErrAuthorNotFound
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) conflictFor(candidate author.Author) error {
|
||||
for _, a := range m.authors {
|
||||
if a.ID == candidate.ID {
|
||||
continue
|
||||
}
|
||||
if a.Slug == candidate.Slug {
|
||||
return author.ErrSlugTaken
|
||||
}
|
||||
if candidate.UserID != nil && a.UserID != nil && *a.UserID == *candidate.UserID {
|
||||
return author.ErrUserAlreadyLinked
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) Create(a author.Author) (author.Author, error) {
|
||||
m.createCalls++
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
a.ID = 0
|
||||
if err := m.conflictFor(a); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
m.nextID++
|
||||
a.ID = m.nextID
|
||||
a.IsLinked = a.UserID != nil
|
||||
m.authors[a.ID] = a
|
||||
return m.withCount(a), nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) Update(a author.Author) (author.Author, error) {
|
||||
if err := m.fail.trigger("Update"); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
if _, ok := m.authors[a.ID]; !ok {
|
||||
return author.Author{}, author.ErrAuthorNotFound
|
||||
}
|
||||
if err := m.conflictFor(a); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
a.UserEmail = nil
|
||||
m.authors[a.ID] = a
|
||||
return m.withCount(a), nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) DeleteMovingCredits(id uint32, reassignTo *uint32) error {
|
||||
m.deleteCalls = append(m.deleteCalls, authorDeleteCall{id: id, reassignTo: reassignTo})
|
||||
if err := m.fail.trigger("DeleteMovingCredits"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := m.authors[id]; !ok {
|
||||
return author.ErrAuthorNotFound
|
||||
}
|
||||
if reassignTo == nil && m.creditCount(id) > 0 {
|
||||
return author.ErrAuthorHasArticles
|
||||
}
|
||||
if reassignTo != nil {
|
||||
if _, ok := m.authors[*reassignTo]; !ok {
|
||||
return author.ErrAuthorNotFound
|
||||
}
|
||||
}
|
||||
m.removeWithCredits(id, reassignTo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) DeleteUncrediting(id uint32) error {
|
||||
m.deleteCalls = append(m.deleteCalls, authorDeleteCall{id: id})
|
||||
if err := m.fail.trigger("DeleteMovingCredits"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := m.authors[id]; !ok {
|
||||
return author.ErrAuthorNotFound
|
||||
}
|
||||
m.removeWithCredits(id, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) creditCount(id uint32) int {
|
||||
total := 0
|
||||
for _, ids := range m.articles.credits {
|
||||
if containsID(ids, id) {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) removeWithCredits(id uint32, reassignTo *uint32) {
|
||||
for articleID, ids := range m.articles.credits {
|
||||
m.articles.credits[articleID] = replaceCredit(ids, id, reassignTo)
|
||||
}
|
||||
delete(m.authors, id)
|
||||
}
|
||||
|
||||
func replaceCredit(ids []uint32, from uint32, to *uint32) []uint32 {
|
||||
result := []uint32{}
|
||||
for _, id := range ids {
|
||||
if id != from {
|
||||
result = append(result, id)
|
||||
continue
|
||||
}
|
||||
if to != nil && !containsID(ids, *to) {
|
||||
result = append(result, *to)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsID(ids []uint32, wanted uint32) bool {
|
||||
for _, id := range ids {
|
||||
if id == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
|
||||
usage := map[uint32]int{}
|
||||
for _, a := range m.authors {
|
||||
if a.AvatarID != nil {
|
||||
usage[*a.AvatarID]++
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) ClearAvatar(imageID uint32) ([]uint32, error) {
|
||||
cleared := []uint32{}
|
||||
for _, id := range m.sortedIDsDescending() {
|
||||
a := m.authors[id]
|
||||
if a.AvatarID != nil && *a.AvatarID == imageID {
|
||||
a.AvatarID = nil
|
||||
m.authors[id] = a
|
||||
cleared = append(cleared, id)
|
||||
}
|
||||
}
|
||||
return cleared, nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error {
|
||||
for _, id := range authorIDs {
|
||||
a := m.authors[id]
|
||||
a.AvatarID = idPointer(imageID)
|
||||
m.authors[id] = a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
|
||||
changed := 0
|
||||
for id, a := range m.authors {
|
||||
if a.AvatarID != nil && containsID(fromImageIDs, *a.AvatarID) {
|
||||
a.AvatarID = idPointer(toImageID)
|
||||
m.authors[id] = a
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
var _ author.Repository = &mockAuthorRepo{}
|
||||
|
||||
const (
|
||||
authorEditorID = 5
|
||||
authorOtherEditorID = 6
|
||||
authorEditorRole = user.Role("editor")
|
||||
)
|
||||
|
||||
type authorEnv struct {
|
||||
*producersEnv
|
||||
authors *mockAuthorRepo
|
||||
}
|
||||
|
||||
func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
t.Helper()
|
||||
p := newProducersEnv(t)
|
||||
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole}
|
||||
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole}
|
||||
p.users.nextID = authorOtherEditorID
|
||||
authors := newMockAuthorRepo(p.articles)
|
||||
sites := plainSites()
|
||||
recorder := p.audit.recorder
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
|
||||
c.MergeMediaUseCase = mergeMedia
|
||||
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
|
||||
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
|
||||
c.ListAuthorsUseCase = useCases.NewListAuthorsUseCase(authors, p.users)
|
||||
c.CreateAuthorUseCase = useCases.NewCreateAuthorUseCase(authors, p.users, p.images, recorder)
|
||||
c.CreateMyAuthorUseCase = useCases.NewCreateMyAuthorUseCase(authors, p.users, recorder)
|
||||
c.UpdateAuthorUseCase = useCases.NewUpdateAuthorUseCase(authors, p.users, p.images, recorder)
|
||||
c.DeleteAuthorUseCase = useCases.NewDeleteAuthorUseCase(authors, recorder)
|
||||
c.GetMyAuthorUseCase = useCases.NewGetMyAuthorUseCase(authors)
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(c, p.settings))
|
||||
p.router = router
|
||||
return &authorEnv{producersEnv: p, authors: authors}
|
||||
}
|
||||
|
||||
func (e *authorEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return e.as(t, apiKeyOwnerId, method, path, body)
|
||||
}
|
||||
|
||||
func (e *authorEnv) seedOnlineArticle(id uint32, title string, authorIDs ...uint32) {
|
||||
e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", IsOnline: true, SiteID: 1}
|
||||
e.articles.credit(id, authorIDs...)
|
||||
}
|
||||
|
||||
func (e *authorEnv) seedOfflineArticle(id uint32, title string, authorIDs ...uint32) {
|
||||
e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", SiteID: 1}
|
||||
e.articles.credit(id, authorIDs...)
|
||||
}
|
||||
|
||||
func (e *authorEnv) creditsOf(articleID uint32) []uint32 {
|
||||
return append([]uint32{}, e.articles.credits[articleID]...)
|
||||
}
|
||||
|
||||
func decodeAuthorBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
type authorListBody struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
func decodeAuthorList(t *testing.T, rec *httptest.ResponseRecorder) authorListBody {
|
||||
t.Helper()
|
||||
var decoded authorListBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode list %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func listedNames(list authorListBody) []string {
|
||||
names := []string{}
|
||||
for _, item := range list.Items {
|
||||
names = append(names, item["name"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func joinNames(names []string) string {
|
||||
return strings.Join(names, "|")
|
||||
}
|
||||
|
||||
func userWithEmail(id uint32, username string, email string) user.User {
|
||||
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole}
|
||||
}
|
||||
|
||||
func userNamed(id uint32, username string, displayName string) user.User {
|
||||
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorsActivityLog(t *testing.T) {
|
||||
t.Run("A successful write is recorded once after the change", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
request string
|
||||
action string
|
||||
id string
|
||||
name string
|
||||
actor uint32
|
||||
prepare func(e *authorEnv)
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{"POST /v1/author/", "author.create", "1", "Camille Roche", apiKeyOwnerId, func(e *authorEnv) {}, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`, http.StatusCreated},
|
||||
{"POST /v1/author/me", "author.create", "1", "Edith Moreau", authorEditorID, func(e *authorEnv) {}, http.MethodPost, "/v1/author/me", "", http.StatusCreated},
|
||||
{"PATCH renaming", "author.edit", "2", "Camille R.", apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "Camille Roche", "camille-roche") }, http.MethodPatch, "/v1/author/2", `{"name": "Camille R."}`, http.StatusOK},
|
||||
{"PATCH linking", "author.edit", "2", "Camille Roche", apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "Camille Roche", "camille-roche") }, http.MethodPatch, "/v1/author/2", `{"user_id": 5}`, http.StatusOK},
|
||||
{"DELETE", "author.delete", "2", "Camille Roche", apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "Camille Roche", "camille-roche") }, http.MethodDelete, "/v1/author/2", "", http.StatusNoContent},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
c.prepare(e)
|
||||
|
||||
rec := e.as(t, c.actor, 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.CategoryAuthors, audit.OutcomeSuccess)
|
||||
assertProducerTarget(t, event, "author", c.id, c.name)
|
||||
if event.Category != "authors" || event.Source != audit.SourceWeb || event.Actor.ID == nil || *event.Actor.ID != c.actor {
|
||||
t.Errorf("event = %+v", event)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The change fields are limited and carry no email", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille R.", "slug": "camille-r", "bio": "New.", "website": "https://c.example", "social_links": [{"type": "x", "url": "https://x.com/c"}], "avatar_id": 5, "user_id": 5}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
event := e.single(t)
|
||||
fields := []string{}
|
||||
for _, change := range event.Changes {
|
||||
fields = append(fields, change.Field)
|
||||
}
|
||||
if !reflect.DeepEqual(fields, []string{"name", "slug", "bio", "website", "social_links", "avatar", "user"}) {
|
||||
t.Errorf("fields = %v", fields)
|
||||
}
|
||||
encoded, _ := json.Marshal(event)
|
||||
if strings.Contains(string(encoded), "@") {
|
||||
t.Errorf("event holds an email: %s", encoded)
|
||||
}
|
||||
if userChange := event.Changes[6]; *userChange.Before != "" || *userChange.After != "edith" {
|
||||
t.Errorf("user change = %+v", userChange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An own-author edit by a role without author rights is recorded", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
|
||||
|
||||
e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", `{"bio": "Hi."}`)
|
||||
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, "author.edit", audit.CategoryAuthors, audit.OutcomeSuccess)
|
||||
if event.Actor.ID == nil || *event.Actor.ID != authorEditorID {
|
||||
t.Errorf("actor = %+v", event.Actor)
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
actor uint32
|
||||
prepare func(e *authorEnv)
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"POST taken slug", "author.create", "That slug is already used by another author.", 409, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(1, "A", "camille-roche") }, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`},
|
||||
{"POST /me linked caller", "author.create", "That user already has an author.", 409, authorEditorID, func(e *authorEnv) { e.authors.seedLinked(1, "Edith", "edith", authorEditorID) }, http.MethodPost, "/v1/author/me", ""},
|
||||
{"PATCH taken slug", "author.edit", "That slug is already used by another author.", 409, apiKeyOwnerId, func(e *authorEnv) {
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(3, "C", "c")
|
||||
}, http.MethodPatch, "/v1/author/2", `{"slug": "c"}`},
|
||||
{"PATCH linking a linked user", "author.edit", "That user already has an author.", 409, apiKeyOwnerId, func(e *authorEnv) {
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seedLinked(3, "C", "c", authorEditorID)
|
||||
}, http.MethodPatch, "/v1/author/2", `{"user_id": 5}`},
|
||||
{"PATCH by a non-admin", "author.edit", "Only an owner or admin can change this author.", 403, authorEditorID, func(e *authorEnv) { e.authors.seed(2, "B", "b") }, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`},
|
||||
{"DELETE with articles", "author.delete", "The author is still credited on articles.", 409, apiKeyOwnerId, func(e *authorEnv) {
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.seedOnlineArticle(1, "One", 2)
|
||||
}, http.MethodDelete, "/v1/author/2", ""},
|
||||
{"DELETE reassigned to itself", "author.delete", "The destination author is not valid.", 400, apiKeyOwnerId, func(e *authorEnv) {
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.seedOnlineArticle(1, "One", 2)
|
||||
}, http.MethodDelete, "/v1/author/2?reassign_to=2", ""},
|
||||
{"POST when the repository fails", "author.create", genericFailureSentence, 500, apiKeyOwnerId, func(e *authorEnv) { e.authors.fail.failNextWith("Create", errors.New("db is locked")) }, http.MethodPost, "/v1/author/", `{"name": "X"}`},
|
||||
{"DELETE when the repository fails", "author.delete", genericFailureSentence, 500, apiKeyOwnerId, func(e *authorEnv) {
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.fail.failNext("DeleteMovingCredits")
|
||||
}, http.MethodDelete, "/v1/author/2", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
c.prepare(e)
|
||||
|
||||
rec := e.as(t, c.actor, 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.CategoryAuthors, 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/author/999", apiKeyOwnerId, http.MethodPatch, "/v1/author/999", `{"name": "X"}`},
|
||||
{"DELETE /v1/author/999", apiKeyOwnerId, http.MethodDelete, "/v1/author/999", ""},
|
||||
{"unknown avatar", apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "X", "avatar_id": 999}`},
|
||||
{"bio too long", apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "X", "bio": "` + strings.Repeat("a", 281) + `"}`},
|
||||
{"403", authorEditorID, http.MethodPost, "/v1/author/", `{"name": "X"}`},
|
||||
{"401", 0, http.MethodPost, "/v1/author/", `{"name": "X"}`},
|
||||
{"PATCH /v1/author/abc", apiKeyOwnerId, http.MethodPatch, "/v1/author/abc", `{"name": "X"}`},
|
||||
{"malformed body", apiKeyOwnerId, http.MethodPost, "/v1/author/", "{not json"},
|
||||
{"unknown user", apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "X", "user_id": 999}`},
|
||||
{"read", apiKeyOwnerId, http.MethodGet, "/v1/author/", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
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": "Camille Roche"}`
|
||||
healthy := newAuthorEnv(t)
|
||||
healthyRec := healthy.owner(t, http.MethodPost, "/v1/author/", body)
|
||||
broken := newAuthorEnv(t)
|
||||
broken.audit.probe.appendErr = errDiskFull
|
||||
|
||||
rec := broken.owner(t, http.MethodPost, "/v1/author/", body)
|
||||
|
||||
if rec.Code != healthyRec.Code || rec.Body.String() != healthyRec.Body.String() || len(broken.authors.authors) != 1 {
|
||||
t.Errorf("broken %d %s, healthy %d %s", rec.Code, rec.Body.String(), healthyRec.Code, healthyRec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An article's authors change is recorded in article.edit", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(5, "Inès Duarte", "ines-duarte")
|
||||
e.authors.seed(6, "Marta Alves", "marta-alves")
|
||||
e.authors.seed(7, "Zoe Lambert", "zoe-lambert")
|
||||
e.seedOnlineArticle(8, "Spring release", 5, 6)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [6, 5, 7]}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, "article.edit", audit.CategoryArticles, audit.OutcomeSuccess)
|
||||
if changes := changesJSON(t, event); changes != `[{"field":"authors","kind":"text","before":"Inès Duarte, Marta Alves","after":"Marta Alves, Inès Duarte, Zoe Lambert"}]` {
|
||||
t.Errorf("changes = %s", changes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting an author with reassignment records only the author event", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille R.", "camille-r")
|
||||
e.authors.seed(3, "Zoe", "zoe")
|
||||
for id := uint32(10); id < 13; id++ {
|
||||
e.seedOnlineArticle(id, "Article", 2)
|
||||
}
|
||||
|
||||
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
|
||||
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, "author.delete", audit.CategoryAuthors, audit.OutcomeSuccess)
|
||||
})
|
||||
|
||||
t.Run("The activity API lists author events", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`)
|
||||
e.owner(t, http.MethodPatch, "/v1/author/1", `{"bio": "Hi."}`)
|
||||
e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/activity/?category=authors", "")
|
||||
|
||||
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())
|
||||
}
|
||||
if len(page.Items) != 2 {
|
||||
t.Fatalf("items = %+v", page.Items)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.Category != "authors" {
|
||||
t.Errorf("category = %s", item.Category)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorFailureReasons(t *testing.T) {
|
||||
t.Run("The author sentinels are not reported as a generic failure", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.fail.failNextWith("Update", author.ErrAuthorAlreadyLinked)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`)
|
||||
|
||||
assertProducerFailure(t, e.single(t), "That author is already linked to a user.")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func (e *authorEnv) seedBylines() {
|
||||
e.authors.seed(5, "Inès Duarte", "ines-duarte")
|
||||
e.authors.seed(6, "Marta Alves", "marta-alves")
|
||||
e.authors.seed(7, "Zoe Lambert", "zoe-lambert")
|
||||
}
|
||||
|
||||
func authorIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []uint32 {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Authors []struct {
|
||||
ID uint32 `json:"id"`
|
||||
} `json:"authors"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
ids := []uint32{}
|
||||
for _, entry := range body.Authors {
|
||||
ids = append(ids, entry.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestAuthorsBackendArticles(t *testing.T) {
|
||||
t.Run("An article is credited with ordered authors", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [6, 5, 7]}`)
|
||||
|
||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{6, 5, 7}) {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The author list is replaced and can be emptied", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 5, 6)
|
||||
|
||||
if rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [6]}`); !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{6}) {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": []}`)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authors":[]`) {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reordering changes the positions", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 5, 6, 7)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [7, 5, 6]}`)
|
||||
|
||||
if got := authorIDsOf(t, e.owner(t, http.MethodGet, "/v1/article/8", "")); !reflect.DeepEqual(got, []uint32{7, 5, 6}) {
|
||||
t.Errorf("authors = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Any account that can edit the article sets its authors", func(t *testing.T) {
|
||||
senders := map[string]func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder{
|
||||
"owner": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
|
||||
},
|
||||
"admin": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
|
||||
},
|
||||
"other role": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, authorEditorID, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
|
||||
},
|
||||
"API key": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.viaKey(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
|
||||
},
|
||||
}
|
||||
for name, send := range senders {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release")
|
||||
if rec := send(e, t); rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The authors and the other fields change together", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New", "author_ids": [5]}`)
|
||||
|
||||
if rec.Code != http.StatusOK || e.articles.articles[8].Body != "New" || !reflect.DeepEqual(e.creditsOf(8), []uint32{5}) {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An update without author_ids keeps the authors", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 5, 6)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New"}`)
|
||||
|
||||
if !reflect.DeepEqual(e.creditsOf(8), []uint32{5, 6}) {
|
||||
t.Errorf("credits = %v", e.creditsOf(8))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unusable author_ids is refused", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"[999]", 404, "author_not_found"},
|
||||
{"[5, 999]", 404, "author_not_found"},
|
||||
{"[5, 5]", 400, apperr.BodyErrorMessage},
|
||||
{"null", 400, apperr.BodyErrorMessage},
|
||||
{`"a"`, 400, apperr.BodyErrorMessage},
|
||||
{"[-1]", 400, apperr.BodyErrorMessage},
|
||||
{"[0]", 400, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.value, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 6)
|
||||
before := e.articles.articles[8]
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": `+c.value+`}`)
|
||||
|
||||
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) || bodyText(rec) == "" {
|
||||
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
if !reflect.DeepEqual(e.articles.articles[8], before) || !reflect.DeepEqual(e.creditsOf(8), []uint32{6}) {
|
||||
t.Errorf("the article changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A refused update changes no authors", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 5)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New", "author_ids": [999]}`)
|
||||
|
||||
if rec.Code != http.StatusNotFound || e.articles.articles[8].Body != "Body" || !reflect.DeepEqual(e.creditsOf(8), []uint32{5}) {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unchanged list records nothing", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 5, 6)
|
||||
|
||||
if rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [5, 6]}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("An article without authors can be published", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedOfflineArticle(8, "Spring release")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8/publish", "")
|
||||
|
||||
if rec.Code != http.StatusOK || !e.articles.articles[8].IsOnline {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Article reads carry the ordered authors", func(t *testing.T) {
|
||||
requests := []struct{ method, path, body string }{
|
||||
{http.MethodGet, "/v1/article/8", ""},
|
||||
{http.MethodPatch, "/v1/article/8", `{"body": "New"}`},
|
||||
{http.MethodPatch, "/v1/article/8/publish", ""},
|
||||
{http.MethodPatch, "/v1/article/8/unpublish", ""},
|
||||
}
|
||||
for _, c := range requests {
|
||||
t.Run(c.method+" "+c.path, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 6, 5)
|
||||
|
||||
rec := e.owner(t, c.method, c.path, c.body)
|
||||
|
||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{6, 5}) {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var parsed struct {
|
||||
Authors []map[string]any `json:"authors"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
||||
keys := []string{}
|
||||
for key := range parsed.Authors[0] {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) != 7 {
|
||||
t.Errorf("author keys = %v", keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("GET /v1/article/ items", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOfflineArticle(8, "Spring release", 6, 5)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/article/", "")
|
||||
var page struct {
|
||||
Items []struct {
|
||||
Authors []struct {
|
||||
ID uint32 `json:"id"`
|
||||
} `json:"authors"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if len(page.Items) != 1 || len(page.Items[0].Authors) != 2 || page.Items[0].Authors[0].ID != 6 {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("An article without authors returns an empty list", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedOfflineArticle(8, "Spring release")
|
||||
if rec := e.owner(t, http.MethodGet, "/v1/article/8", ""); !strings.Contains(rec.Body.String(), `"authors":[]`) {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Anonymous readers see the authors of online articles", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(5, "Inès Duarte", "ines-duarte", authorEditorID)
|
||||
e.seedOnlineArticle(9, "Spring release", 5)
|
||||
|
||||
rec := e.as(t, 0, http.MethodGet, "/v1/article/9", "")
|
||||
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(body, "Inès Duarte") || strings.Contains(body, "user_id") || strings.Contains(body, "user_email") || strings.Contains(body, "article_count") {
|
||||
t.Errorf("status %d body %s", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An offline article is still not found publicly", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedOfflineArticle(8, "Spring release")
|
||||
expectAuthorBody(t, e.as(t, 0, http.MethodGet, "/v1/article/8", ""), http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("A new article defaults to the creator's author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(5, "Edith", "edith", authorEditorID)
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
||||
|
||||
if rec.Code != http.StatusCreated || !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{5}) || e.articles.articles[1].IsOnline {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A creator without an author creates an article without authors", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
||||
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"authors":[]`) {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting an article removes its credits and keeps the author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(5, "Inès Duarte", "ines-duarte")
|
||||
e.seedOnlineArticle(8, "One", 5)
|
||||
e.seedOnlineArticle(9, "Two", 5)
|
||||
|
||||
if rec := e.owner(t, http.MethodDelete, "/v1/article/8", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
got, err := e.authors.Get(5)
|
||||
if err != nil || got.ArticleCount != 1 {
|
||||
t.Errorf("author %+v %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("article_count counts offline articles too", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(5, "Inès Duarte", "ines-duarte")
|
||||
e.seedOnlineArticle(8, "One", 5)
|
||||
e.seedOfflineArticle(9, "Two", 5)
|
||||
if got, _ := e.authors.Get(5); got.ArticleCount != 2 {
|
||||
t.Errorf("article_count = %d", got.ArticleCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list filter and counts are unaffected", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBylines()
|
||||
e.seedOnlineArticle(8, "Spring release", 5)
|
||||
e.seedOfflineArticle(9, "Spring draft", 6)
|
||||
e.seedOnlineArticle(10, "Autumn", 7)
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/article/?status=online&q=spring", "")
|
||||
|
||||
var page struct {
|
||||
Total int `json:"total"`
|
||||
Counts struct {
|
||||
All int `json:"all"`
|
||||
Online int `json:"online"`
|
||||
Draft int `json:"draft"`
|
||||
} `json:"counts"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if page.Total != 1 || page.Counts.All != 3 || page.Counts.Online != 2 || page.Counts.Draft != 1 {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsBackendCurrentUser(t *testing.T) {
|
||||
t.Run("GET /v1/auth/me includes the linked author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(5, "Edith", "edith", authorEditorID)
|
||||
e.seedOnlineArticle(8, "One", 5)
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
linked, _ := body["author"].(map[string]any)
|
||||
if rec.Code != http.StatusOK || linked == nil || linked["id"] != float64(5) || linked["article_count"] != float64(1) {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, key := range []string{"id", "name", "slug", "bio", "avatar_id", "website", "social_links", "article_count"} {
|
||||
if _, present := linked[key]; !present {
|
||||
t.Errorf("missing %s", key)
|
||||
}
|
||||
}
|
||||
if _, present := linked["user_email"]; present || strings.Contains(rec.Body.String(), "user_id") {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user without an author has author null", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
rec := e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")
|
||||
if body := decodeAuthorBody(t, rec); body["author"] != nil {
|
||||
t.Errorf("author = %v", body["author"])
|
||||
}
|
||||
if _, present := decodeAuthorBody(t, rec)["author"]; !present {
|
||||
t.Errorf("author key missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The field follows link changes", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(5, "Edith", "edith", authorEditorID)
|
||||
e.owner(t, http.MethodPatch, "/v1/author/5", `{"user_id": null}`)
|
||||
if body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")); body["author"] != nil {
|
||||
t.Errorf("author = %v", body["author"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The existing fields are unchanged", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", ""))
|
||||
for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} {
|
||||
if _, present := body[key]; !present {
|
||||
t.Errorf("missing %s", key)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
for _, secret := range []string{"password", "token", "code"} {
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Errorf("body holds %s", secret)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An API key reads the same body", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(5, "Dev", "dev", apiKeyDevId)
|
||||
rec := e.viaKey(t, http.MethodGet, "/v1/auth/me", "")
|
||||
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusOK || body["author"] == nil {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (e *authorEnv) useImageForAuthor(authorID uint32, imageID uint32) {
|
||||
e.authors.update(authorID, func(a *author.Author) { a.AvatarID = idPointer(imageID) })
|
||||
}
|
||||
|
||||
func TestAuthorsBackendMediaUsage(t *testing.T) {
|
||||
t.Run("An image used as an avatar is counted as used", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 5)
|
||||
|
||||
var list, unused mediaListBody
|
||||
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/media/", "").Body.Bytes(), &list)
|
||||
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/media/?filter=unused", "").Body.Bytes(), &unused)
|
||||
|
||||
if len(list.Items) != 1 || list.Items[0].UsedIn != 1 || list.Counts.Unused != 0 || len(unused.Items) != 0 {
|
||||
t.Errorf("list %+v unused %+v", list, unused)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The media list counts the author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 5)
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
|
||||
var list mediaListBody
|
||||
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/media/", "").Body.Bytes(), &list)
|
||||
|
||||
if len(list.Items) != 1 || list.Items[0].UsedIn != 2 {
|
||||
t.Errorf("list %+v", list)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting the image clears the author's avatar", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 5)
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
|
||||
|
||||
if rec.Code != http.StatusNoContent || e.authors.authors[2].AvatarID != nil {
|
||||
t.Errorf("status %d avatar %v", rec.Code, e.authors.authors[2].AvatarID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed image deletion keeps the author's avatar", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 5)
|
||||
e.images.failDeleteFor = map[uint32]error{5: errRepositoryFailure}
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || e.authors.authors[2].AvatarID == nil {
|
||||
t.Errorf("status %d avatar %v", rec.Code, e.authors.authors[2].AvatarID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Merging copies repoints the author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "samehash")
|
||||
e.seedBanner(6, "banner-copy.png", "samehash")
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 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)
|
||||
avatar := e.authors.authors[2].AvatarID
|
||||
if rec.Code != http.StatusOK || body["authors_updated"] != 1 || avatar == nil || *avatar != 5 {
|
||||
t.Errorf("status %d body %s avatar %v", rec.Code, rec.Body.String(), avatar)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Purge repoints authors the same way", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "samehash")
|
||||
e.seedBanner(6, "banner-copy.png", "samehash")
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 6)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
|
||||
|
||||
var body map[string]int
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
avatar := e.authors.authors[2].AvatarID
|
||||
if rec.Code != http.StatusOK || body["authors_updated"] != 1 || avatar == nil || *avatar != 5 {
|
||||
t.Errorf("status %d body %s avatar %v", rec.Code, rec.Body.String(), avatar)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed purge group leaves the author pointing at an existing image", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "samehash")
|
||||
e.seedBanner(6, "banner-copy.png", "samehash")
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 6)
|
||||
e.images.failDeleteFor = map[uint32]error{6: errRepositoryFailure}
|
||||
|
||||
e.owner(t, http.MethodPost, "/v1/media/purge", "")
|
||||
|
||||
avatar := e.authors.authors[2].AvatarID
|
||||
if avatar == nil {
|
||||
t.Fatalf("the avatar was cleared")
|
||||
}
|
||||
if _, exists := e.images.images[*avatar]; !exists {
|
||||
t.Errorf("the author points at the missing image %d", *avatar)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.authors.seed(2, "Camille", "camille")
|
||||
e.useImageForAuthor(2, 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(), "author") {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func (e *authorEnv) seedEditable() {
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
e.authors.update(2, func(a *author.Author) {
|
||||
a.Bio = "Writer."
|
||||
a.Website = stringPointer("https://camille.example")
|
||||
a.AvatarID = idPointer(5)
|
||||
a.SocialLinks = []author.SocialLink{{Type: "x", URL: "https://x.com/c"}, {Type: "github", URL: "https://github.com/c"}}
|
||||
})
|
||||
e.images.images[5] = image.Image{ID: 5, Filename: "avatar.png", Path: "/static/uploadedImages/5.png"}
|
||||
}
|
||||
|
||||
func (e *authorEnv) unchanged(t *testing.T, id uint32, before author.Author) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(e.authors.authors[id], before) {
|
||||
t.Errorf("author %d changed: %+v, was %+v", id, e.authors.authors[id], before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorsBackendEditing(t *testing.T) {
|
||||
t.Run("The owner edits any author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille R.", "slug": "camille-r", "bio": "New.", "website": "https://camille.example/new", "social_links": [{"type": "github", "url": "https://github.com/camille"}]}`)
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if rec.Code != http.StatusOK || body["name"] != "Camille R." || body["slug"] != "camille-r" || body["bio"] != "New." || body["website"] != "https://camille.example/new" {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
if links := e.authors.authors[2].SocialLinks; len(links) != 1 || links[0].Type != "github" {
|
||||
t.Errorf("links = %+v", links)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Absent fields are untouched", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
before := e.authors.authors[2]
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille R."}`)
|
||||
|
||||
expected := before
|
||||
expected.Name = "Camille R."
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
e.unchanged(t, 2, expected)
|
||||
})
|
||||
|
||||
t.Run("Nullable fields are cleared", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
assert func(a author.Author) bool
|
||||
}{
|
||||
{`{"avatar_id": null}`, func(a author.Author) bool { return a.AvatarID == nil }},
|
||||
{`{"website": null}`, func(a author.Author) bool { return a.Website == nil }},
|
||||
{`{"website": ""}`, func(a author.Author) bool { return a.Website == nil }},
|
||||
{`{"social_links": []}`, func(a author.Author) bool { return len(a.SocialLinks) == 0 }},
|
||||
{`{"bio": ""}`, func(a author.Author) bool { return a.Bio == "" }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.body, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", c.body)
|
||||
|
||||
if rec.Code != http.StatusOK || !c.assert(e.authors.authors[2]) {
|
||||
t.Errorf("status %d stored %+v", rec.Code, e.authors.authors[2])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The avatar is set to an existing image", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
e.authors.update(2, func(a *author.Author) { a.AvatarID = nil })
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/author/2", `{"avatar_id": 5}`)
|
||||
|
||||
if avatar := e.authors.authors[2].AvatarID; avatar == nil || *avatar != 5 {
|
||||
t.Errorf("avatar = %v", avatar)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Social links are replaced as a whole", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/author/2", `{"social_links": [{"type": "x", "url": "https://x.com/a"}]}`)
|
||||
|
||||
if !reflect.DeepEqual(e.authors.authors[2].SocialLinks, []author.SocialLink{{Type: "x", URL: "https://x.com/a"}}) {
|
||||
t.Errorf("links = %+v", e.authors.authors[2].SocialLinks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Editing is validated like creation", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
token string
|
||||
}{
|
||||
{"empty name", `{"name": ""}`, 400, ""},
|
||||
{"bad slug", `{"slug": "Bad Slug"}`, 400, "invalid_slug"},
|
||||
{"empty slug", `{"slug": ""}`, 400, "invalid_slug"},
|
||||
{"long bio", `{"bio": "` + strings.Repeat("a", 281) + `"}`, 422, "bio_too_long"},
|
||||
{"http website", `{"website": "http://example.com"}`, 422, "invalid_url"},
|
||||
{"six links", `{"social_links": [` + strings.TrimSuffix(strings.Repeat(`{"type": "x", "url": "https://x.com/a"},`, 6), ",") + `]}`, 422, "too_many_links"},
|
||||
{"unknown avatar", `{"avatar_id": 999}`, 400, "image_not_found"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
before := e.authors.authors[2]
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", c.body)
|
||||
|
||||
if rec.Code != c.status || (c.token != "" && bodyText(rec) != c.token) || bodyText(rec) == "" {
|
||||
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
e.unchanged(t, 2, before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Keeping the own slug is not a collision", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
if rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"slug": "camille-roche", "bio": "x"}`); rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Taking another author's slug is refused", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
e.authors.seed(3, "Zoe", "zoe-lambert")
|
||||
before := e.authors.authors[2]
|
||||
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", `{"slug": "zoe-lambert"}`), http.StatusConflict, "author_slug_taken")
|
||||
e.unchanged(t, 2, before)
|
||||
})
|
||||
|
||||
t.Run("An empty or unknown update is refused", func(t *testing.T) {
|
||||
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"name": null}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
before := e.authors.authors[2]
|
||||
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", body), http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
e.unchanged(t, 2, before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A missing author is not found", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/999", `{"name": "X"}`), http.StatusNotFound, "author_not_found")
|
||||
})
|
||||
|
||||
t.Run("A malformed id is refused", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/abc", `{"name": "X"}`), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("A no-op edit records nothing", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille Roche", "slug": "camille-roche", "bio": "Writer."}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A role without author rights edits only its own author", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
id uint32
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{4, 200, ""},
|
||||
{2, 403, "author_rights"},
|
||||
{999, 404, "author_not_found"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(fmt.Sprint(c.id), func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPatch, fmt.Sprintf("/v1/author/%d", c.id), `{"bio": "Updated."}`)
|
||||
|
||||
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) {
|
||||
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Own author edits include name, slug and avatar", func(t *testing.T) {
|
||||
for _, body := range []string{`{"name": "Marta A."}`, `{"slug": "marta-a"}`, `{"avatar_id": 5}`, `{"social_links": []}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
|
||||
if rec := e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", body); rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A role without author rights cannot change the link", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
|
||||
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", `{"user_id": null}`), http.StatusForbidden, "author_rights")
|
||||
if e.authors.authors[4].UserID == nil {
|
||||
t.Errorf("the author was unlinked")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An API key cannot edit an author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
expectAuthorBody(t, e.viaKey(t, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`), http.StatusForbidden, sessionRefusal)
|
||||
})
|
||||
|
||||
t.Run("The response carries the email for owner and admin only", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
|
||||
|
||||
own := e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", `{"bio": "Updated."}`)
|
||||
admin := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/author/4", `{"bio": "Again."}`)
|
||||
|
||||
if strings.Contains(own.Body.String(), "user_email") || !strings.Contains(admin.Body.String(), "edith@example.com") {
|
||||
t.Errorf("own %s admin %s", own.Body.String(), admin.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsBackendLinking(t *testing.T) {
|
||||
t.Run("The owner links a user to a guest author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorEditorID))
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if rec.Code != http.StatusOK || body["user_id"] != float64(authorEditorID) || body["is_linked"] != true || body["user_email"] != "edith@example.com" {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user cannot have two authors", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
|
||||
before := e.authors.authors[2]
|
||||
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorEditorID)), http.StatusConflict, "user_already_linked")
|
||||
e.unchanged(t, 2, before)
|
||||
})
|
||||
|
||||
t.Run("A linked author must be unlinked before it can be relinked", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
|
||||
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorOtherEditorID)), http.StatusConflict, "author_already_linked")
|
||||
})
|
||||
|
||||
t.Run("Linking the user it already has is a no-op", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorEditorID))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("An unknown user cannot be linked", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": 999}`), http.StatusNotFound, "user_not_found")
|
||||
})
|
||||
|
||||
t.Run("The owner unlinks a user", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
|
||||
e.seedOnlineArticle(10, "Spring release", 2)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": null}`)
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if _, present := body["user_email"]; rec.Code != http.StatusOK || body["user_id"] != nil || body["is_linked"] != false || present {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
if !reflect.DeepEqual(e.creditsOf(10), []uint32{2}) {
|
||||
t.Errorf("credits = %v", e.creditsOf(10))
|
||||
}
|
||||
if rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusCreated {
|
||||
t.Errorf("the user cannot create an author again: %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unlinking a guest author is a no-op", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": null}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A new article by the unlinked user no longer starts with the author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
|
||||
e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": null}`)
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
||||
|
||||
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusCreated || len(body["authors"].([]any)) != 0 {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsBackendDeleting(t *testing.T) {
|
||||
t.Run("An author without articles is deleted", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
|
||||
if rec := e.owner(t, http.MethodDelete, "/v1/author/2", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, exists := e.authors.authors[2]; exists {
|
||||
t.Errorf("the author still exists")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An author with articles needs a destination", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
for id := uint32(10); id < 13; id++ {
|
||||
e.seedOnlineArticle(id, "Article", 2)
|
||||
}
|
||||
|
||||
expectAuthorBody(t, e.owner(t, http.MethodDelete, "/v1/author/2", ""), http.StatusConflict, "author_has_articles")
|
||||
if _, exists := e.authors.authors[2]; !exists || len(e.authors.deleteCalls) != 0 {
|
||||
t.Errorf("the author changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The articles are reassigned to another author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "Camille Roche", "camille-roche")
|
||||
e.authors.seed(3, "Zoe", "zoe")
|
||||
for id := uint32(10); id < 13; id++ {
|
||||
e.seedOnlineArticle(id, "Article", 2)
|
||||
}
|
||||
|
||||
if rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for id := uint32(10); id < 13; id++ {
|
||||
if !reflect.DeepEqual(e.creditsOf(id), []uint32{3}) {
|
||||
t.Errorf("credits of %d = %v", id, e.creditsOf(id))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The reassigned author takes the position of the deleted one", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(3, "C", "c")
|
||||
e.authors.seed(5, "E", "e")
|
||||
e.authors.seed(6, "F", "f")
|
||||
e.seedOnlineArticle(10, "Article", 5, 2, 6)
|
||||
|
||||
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
|
||||
|
||||
if !reflect.DeepEqual(e.creditsOf(10), []uint32{5, 3, 6}) {
|
||||
t.Errorf("credits = %v", e.creditsOf(10))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A reassignment never duplicates an author on an article", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(3, "C", "c")
|
||||
e.seedOnlineArticle(10, "Article", 2, 3)
|
||||
|
||||
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
|
||||
|
||||
if !reflect.DeepEqual(e.creditsOf(10), []uint32{3}) {
|
||||
t.Errorf("credits = %v", e.creditsOf(10))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Nobody removes the byline", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(5, "E", "e")
|
||||
e.authors.seed(6, "F", "f")
|
||||
e.seedOnlineArticle(10, "Alone", 2)
|
||||
e.seedOnlineArticle(11, "Shared", 5, 2, 6)
|
||||
|
||||
if rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=none", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(e.creditsOf(10)) != 0 || !reflect.DeepEqual(e.creditsOf(11), []uint32{5, 6}) {
|
||||
t.Errorf("credits = %v %v", e.creditsOf(10), e.creditsOf(11))
|
||||
}
|
||||
if _, exists := e.articles.articles[10]; !exists {
|
||||
t.Errorf("the article was removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(3, "C", "c")
|
||||
e.seedOnlineArticle(10, "Article", 2)
|
||||
e.authors.fail.failNext("DeleteMovingCredits")
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
if _, exists := e.authors.authors[2]; !exists || !reflect.DeepEqual(e.creditsOf(10), []uint32{2}) {
|
||||
t.Errorf("state changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The credits are moved by one repository operation", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(3, "C", "c")
|
||||
e.seedOnlineArticle(10, "Article", 2)
|
||||
|
||||
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
|
||||
|
||||
if len(e.authors.deleteCalls) != 1 || e.authors.deleteCalls[0].id != 2 || e.authors.deleteCalls[0].reassignTo == nil || *e.authors.deleteCalls[0].reassignTo != 3 {
|
||||
t.Errorf("calls = %+v", e.authors.deleteCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unusable destination", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
param string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"999", 404, "author_not_found"},
|
||||
{"2", 400, "invalid_reassign"},
|
||||
{"abc", 400, "invalid_reassign"},
|
||||
{"-1", 400, "invalid_reassign"},
|
||||
{"", 400, "invalid_reassign"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.param, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.seedOnlineArticle(10, "Article", 2)
|
||||
|
||||
expectAuthorBody(t, e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to="+c.param, ""), c.status, c.body)
|
||||
if _, exists := e.authors.authors[2]; !exists || !reflect.DeepEqual(e.creditsOf(10), []uint32{2}) {
|
||||
t.Errorf("state changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A linked author can be deleted and the account is untouched", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(2, "Edith", "edith", authorEditorID)
|
||||
|
||||
e.owner(t, http.MethodDelete, "/v1/author/2", "")
|
||||
|
||||
if _, exists := e.users.users[authorEditorID]; !exists {
|
||||
t.Errorf("the user was removed")
|
||||
}
|
||||
if rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusCreated {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A missing author is not found", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
expectAuthorBody(t, e.owner(t, http.MethodDelete, "/v1/author/999", ""), http.StatusNotFound, "author_not_found")
|
||||
})
|
||||
|
||||
t.Run("An unused reassign_to is ignored", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
e.authors.seed(3, "C", "c")
|
||||
before := e.authors.authors[3]
|
||||
|
||||
if rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.unchanged(t, 3, before)
|
||||
})
|
||||
|
||||
t.Run("Other roles and keys cannot delete", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(2, "Edith", "edith", authorEditorID)
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodDelete, "/v1/author/2", ""), http.StatusForbidden, ownerOrAdminRefusal)
|
||||
expectAuthorBody(t, e.viaKey(t, http.MethodDelete, "/v1/author/2", ""), http.StatusForbidden, sessionRefusal)
|
||||
if _, exists := e.authors.authors[2]; !exists {
|
||||
t.Errorf("the author was deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin can delete", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(2, "B", "b")
|
||||
if rec := e.as(t, apiKeyDevId, http.MethodDelete, "/v1/author/2", ""); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refused writes change nothing", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.seedEditable()
|
||||
before := e.authors.snapshot()
|
||||
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/", `{"name": "X"}`), http.StatusForbidden, ownerOrAdminRefusal)
|
||||
expectAuthorBody(t, e.viaKey(t, http.MethodPost, "/v1/author/", `{"name": "X"}`), http.StatusForbidden, sessionRefusal)
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`), http.StatusForbidden, "author_rights")
|
||||
if !reflect.DeepEqual(e.authors.snapshot(), before) {
|
||||
t.Errorf("authors changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ownerOrAdminRefusal = "Only owners and admins can do this."
|
||||
sessionRefusal = "This action needs a real session."
|
||||
)
|
||||
|
||||
func (e *authorEnv) viaKey(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
||||
return e.asKey(t, raw, method, path, body)
|
||||
}
|
||||
|
||||
func expectAuthorBody(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 TestAuthorsBackendReading(t *testing.T) {
|
||||
t.Run("The list is newest first with article counts", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Léa Fontaine", "lea-fontaine")
|
||||
e.authors.seed(2, "Inès Duarte", "ines-duarte")
|
||||
e.authors.seed(3, "Camille Roche", "camille-roche")
|
||||
for id := uint32(1); id <= 9; id++ {
|
||||
e.seedOnlineArticle(id, fmt.Sprintf("Article %d", id), 2)
|
||||
}
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/author/", "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
list := decodeAuthorList(t, rec)
|
||||
if list.Total != 3 || list.Page != 1 || list.Size != 25 {
|
||||
t.Errorf("envelope = %+v", list)
|
||||
}
|
||||
if joinNames(listedNames(list)) != "Camille Roche|Inès Duarte|Léa Fontaine" {
|
||||
t.Errorf("names = %v", listedNames(list))
|
||||
}
|
||||
counts := []float64{}
|
||||
for _, item := range list.Items {
|
||||
counts = append(counts, item["article_count"].(float64))
|
||||
}
|
||||
if !reflect.DeepEqual(counts, []float64{0, 9, 0}) {
|
||||
t.Errorf("article_count = %v", counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Who can read the list", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
credentials string
|
||||
send func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder
|
||||
status int
|
||||
}{
|
||||
{"the owner's jwt cookie", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/author/", "")
|
||||
}, http.StatusOK},
|
||||
{"a verified admin's jwt cookie", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "")
|
||||
}, http.StatusOK},
|
||||
{"a verified account with another role", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
|
||||
}, http.StatusOK},
|
||||
{"a valid API key", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.viaKey(t, http.MethodGet, "/v1/author/", "")
|
||||
}, http.StatusOK},
|
||||
{"an unverified account's jwt cookie", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/author/", "")
|
||||
}, http.StatusForbidden},
|
||||
{"no credentials", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, 0, http.MethodGet, "/v1/author/", "")
|
||||
}, http.StatusUnauthorized},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.credentials, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Camille Roche", "camille-roche")
|
||||
if rec := c.send(e, t); rec.Code != c.status {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Search matches the name or the slug", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
q string
|
||||
names string
|
||||
total int
|
||||
}{
|
||||
{"camille", "Camille Roche", 1},
|
||||
{"ROCHE", "Camille Roche", 1},
|
||||
{"zl", "Zoe Lambert", 1},
|
||||
{"zoe", "Zoe Lambert", 1},
|
||||
{"nobody", "", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.q, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Camille Roche", "camille-roche")
|
||||
e.authors.seed(2, "Zoe Lambert", "zl")
|
||||
|
||||
list := decodeAuthorList(t, e.owner(t, http.MethodGet, "/v1/author/?q="+c.q, ""))
|
||||
|
||||
if joinNames(listedNames(list)) != c.names || list.Total != c.total {
|
||||
t.Errorf("names = %v total = %d", listedNames(list), list.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pagination follows the article list", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
params string
|
||||
status int
|
||||
items int
|
||||
total int
|
||||
page int
|
||||
size int
|
||||
}{
|
||||
{"size=25&page=2", 200, 5, 30, 2, 25},
|
||||
{"size=5", 200, 5, 30, 1, 5},
|
||||
{"page=3&size=100", 200, 0, 30, 3, 100},
|
||||
{"page=9223372036854775807&size=100", 200, 0, 30, 9223372036854775807, 100},
|
||||
{"size=30", 400, 0, 0, 0, 0},
|
||||
{"page=0", 400, 0, 0, 0, 0},
|
||||
{"page=abc", 400, 0, 0, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.params, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
for id := uint32(1); id <= 30; id++ {
|
||||
e.authors.seed(id, fmt.Sprintf("Author %02d", id), fmt.Sprintf("author-%02d", id))
|
||||
}
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/author/?"+c.params, "")
|
||||
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.status == http.StatusBadRequest {
|
||||
expectAuthorBody(t, rec, c.status, apperr.BodyErrorMessage)
|
||||
return
|
||||
}
|
||||
list := decodeAuthorList(t, rec)
|
||||
if len(list.Items) != c.items || list.Total != c.total || list.Page != c.page || list.Size != c.size {
|
||||
t.Errorf("items %d, %+v", len(list.Items), list)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The account email is shown to owner and admin only", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.users.users[7] = userWithEmail(7, "ines", "ines.duarte@renew.example")
|
||||
e.authors.seedLinked(1, "Inès Duarte", "ines-duarte", 7)
|
||||
|
||||
owner := decodeAuthorList(t, e.owner(t, http.MethodGet, "/v1/author/", ""))
|
||||
item := owner.Items[0]
|
||||
if item["is_linked"] != true || item["user_id"] != float64(7) || item["user_email"] != "ines.duarte@renew.example" {
|
||||
t.Errorf("owner item = %v", item)
|
||||
}
|
||||
rec := e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
|
||||
if !strings.Contains(rec.Body.String(), `"is_linked":true`) || strings.Contains(rec.Body.String(), "user_email") || strings.Contains(rec.Body.String(), "ines.duarte@renew.example") {
|
||||
t.Errorf("editor body = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A guest author is not linked", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Camille Roche", "camille-roche")
|
||||
|
||||
item := decodeAuthorList(t, e.owner(t, http.MethodGet, "/v1/author/", "")).Items[0]
|
||||
|
||||
if item["is_linked"] != false || item["user_id"] != nil {
|
||||
t.Errorf("item = %v", item)
|
||||
}
|
||||
if _, present := item["user_email"]; present {
|
||||
t.Errorf("user_email present: %v", item)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An author without avatar or website returns nulls", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Camille Roche", "camille-roche")
|
||||
e.authors.update(1, func(a *author.Author) { a.SocialLinks = nil })
|
||||
|
||||
item := decodeAuthorList(t, e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")).Items[0]
|
||||
|
||||
links, _ := item["social_links"].([]any)
|
||||
if item["avatar_id"] != nil || item["website"] != nil || item["bio"] != "" || links == nil || len(links) != 0 {
|
||||
t.Errorf("item = %v", item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsBackendCreating(t *testing.T) {
|
||||
t.Run("An owner or admin creates an author", func(t *testing.T) {
|
||||
for _, actor := range []uint32{apiKeyOwnerId, apiKeyDevId} {
|
||||
t.Run(fmt.Sprintf("user %d", actor), func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
|
||||
rec := e.as(t, actor, http.MethodPost, "/v1/author/", `{"name": "Camille Roche", "slug": "camille-roche", "bio": "Writer.", "website": "https://example.com", "social_links": [{"type": "x", "url": "https://x.com/camille"}]}`)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if body["id"] != float64(1) || body["article_count"] != float64(0) || body["is_linked"] != false || body["name"] != "Camille Roche" || body["slug"] != "camille-roche" || body["bio"] != "Writer." || body["website"] != "https://example.com" {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
if stored := e.authors.authors[1]; stored.Name != "Camille Roche" || len(stored.SocialLinks) != 1 || stored.SocialLinks[0].Type != "x" {
|
||||
t.Errorf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only the name is required", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Zoe Lambert"}`)
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
links, _ := body["social_links"].([]any)
|
||||
if rec.Code != http.StatusCreated || body["slug"] != "zoe-lambert" || body["bio"] != "" || body["avatar_id"] != nil || body["website"] != nil || body["user_id"] != nil || links == nil || len(links) != 0 {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The slug is generated from the name", func(t *testing.T) {
|
||||
cases := [][2]string{
|
||||
{"Camille Roche", "camille-roche"},
|
||||
{"Inès Duarte", "ines-duarte"},
|
||||
{"Dr. O'Neil Smith", "dr-o-neil-smith"},
|
||||
{"!!!", "author"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c[0], func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
body := decodeAuthorBody(t, e.owner(t, http.MethodPost, "/v1/author/", `{"name": "`+c[0]+`"}`))
|
||||
if body["slug"] != c[1] {
|
||||
t.Errorf("slug = %v", body["slug"])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A taken slug is refused", func(t *testing.T) {
|
||||
cases := []struct{ name, body string }{
|
||||
{"generated", `{"name": "Camille Roche"}`},
|
||||
{"explicit", `{"name": "Other", "slug": "camille-roche"}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Camille Roche", "camille-roche")
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/author/", c.body)
|
||||
|
||||
expectAuthorBody(t, rec, http.StatusConflict, "author_slug_taken")
|
||||
if len(e.authors.authors) != 1 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The slug is unique even if the repository reports a race", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.fail.failNextWith("Create", author.ErrSlugTaken)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Fresh Name", "slug": "fresh-name"}`)
|
||||
|
||||
expectAuthorBody(t, rec, http.StatusConflict, "author_slug_taken")
|
||||
})
|
||||
|
||||
t.Run("Fields are validated", func(t *testing.T) {
|
||||
longLinks := strings.Repeat(`{"type": "x", "url": "https://x.com/a"},`, 5) + `{"type": "x", "url": "https://x.com/a"}`
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
token string
|
||||
}{
|
||||
{"empty name", `{"name": ""}`, 400, ""},
|
||||
{"blank name", `{"name": " "}`, 400, ""},
|
||||
{"long name", `{"name": "` + strings.Repeat("a", 101) + `"}`, 400, ""},
|
||||
{"capital slug", `{"name": "A", "slug": "Camille"}`, 400, "invalid_slug"},
|
||||
{"underscore slug", `{"name": "A", "slug": "camille_roche"}`, 400, "invalid_slug"},
|
||||
{"leading dash slug", `{"name": "A", "slug": "-camille"}`, 400, "invalid_slug"},
|
||||
{"long slug", `{"name": "A", "slug": "` + strings.Repeat("a", 101) + `"}`, 400, "invalid_slug"},
|
||||
{"long bio", `{"name": "A", "bio": "` + strings.Repeat("a", 281) + `"}`, 422, "bio_too_long"},
|
||||
{"http website", `{"name": "A", "website": "http://example.com"}`, 422, "invalid_url"},
|
||||
{"bare website", `{"name": "A", "website": "example.com"}`, 422, "invalid_url"},
|
||||
{"hostless website", `{"name": "A", "website": "https://"}`, 422, "invalid_url"},
|
||||
{"six links", `{"name": "A", "social_links": [` + longLinks + `]}`, 422, "too_many_links"},
|
||||
{"http link", `{"name": "A", "social_links": [{"type": "x", "url": "http://x.com/a"}]}`, 422, "invalid_url"},
|
||||
{"unknown link type", `{"name": "A", "social_links": [{"type": "facebook", "url": "https://f.com/a"}]}`, 400, ""},
|
||||
{"unknown avatar", `{"name": "A", "avatar_id": 999}`, 400, "image_not_found"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/author/", c.body)
|
||||
|
||||
if rec.Code != c.status || (c.token != "" && bodyText(rec) != c.token) || (c.token == "" && bodyText(rec) == "") {
|
||||
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
if len(e.authors.authors) != 0 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Boundaries are accepted", func(t *testing.T) {
|
||||
fiveLinks := strings.TrimSuffix(strings.Repeat(`{"type": "x", "url": "https://x.com/a"},`, 5), ",")
|
||||
cases := []struct{ name, body string }{
|
||||
{"100 character name", `{"name": "` + strings.Repeat("a", 100) + `", "slug": "` + strings.Repeat("b", 100) + `"}`},
|
||||
{"280 characters bio", `{"name": "A", "bio": "` + strings.Repeat("a", 280) + `"}`},
|
||||
{"280 multi-byte characters bio", `{"name": "A", "bio": "` + strings.Repeat("é", 280) + `"}`},
|
||||
{"five links", `{"name": "A", "social_links": [` + fiveLinks + `]}`},
|
||||
{"two custom links", `{"name": "A", "social_links": [{"type": "custom", "url": "https://a.example"}, {"type": "custom", "url": "https://b.example"}]}`},
|
||||
{"empty website", `{"name": "A", "website": ""}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
if rec := e.owner(t, http.MethodPost, "/v1/author/", c.body); rec.Code != http.StatusCreated {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A malformed body is refused", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
expectAuthorBody(t, e.owner(t, http.MethodPost, "/v1/author/", "{not json"), http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
if len(e.authors.authors) != 0 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The role is checked before the body", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/", "{not json"), http.StatusForbidden, ownerOrAdminRefusal)
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/", `{"name": "Valid Name"}`), http.StatusForbidden, ownerOrAdminRefusal)
|
||||
if len(e.authors.authors) != 0 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An author can be created linked to a user", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Marta Alves", "user_id": 1}`)
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if rec.Code != http.StatusCreated || body["user_id"] != float64(1) || body["is_linked"] != true || body["user_email"] != "marta@example.com" {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An author cannot be created with an unusable user", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
userID int
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"unknown user", 999, 404, "user_not_found"},
|
||||
{"user with an author", 5, 409, "user_already_linked"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(1, "Edith", "edith", authorEditorID)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/author/", fmt.Sprintf(`{"name": "X", "user_id": %d}`, c.userID))
|
||||
|
||||
expectAuthorBody(t, rec, c.status, c.body)
|
||||
if len(e.authors.authors) != 1 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsBackendOwnAuthor(t *testing.T) {
|
||||
t.Run("The author is named from the display name or the username", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
display, username, name, slug string
|
||||
}{
|
||||
{"Marta Alves", "marta", "Marta Alves", "marta-alves"},
|
||||
{"", "marta", "marta", "marta"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.users.users[authorEditorID] = userNamed(authorEditorID, c.username, c.display)
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "")
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if rec.Code != http.StatusCreated || body["name"] != c.name || body["slug"] != c.slug || body["user_id"] != float64(authorEditorID) || body["is_linked"] != true {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Any verified role may do it", func(t *testing.T) {
|
||||
for _, actor := range []uint32{apiKeyOwnerId, apiKeyDevId, authorEditorID} {
|
||||
t.Run(fmt.Sprintf("user %d", actor), func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
if rec := e.as(t, actor, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusCreated {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A taken slug is de-duplicated silently", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.users.users[authorEditorID] = userNamed(authorEditorID, "marta", "Marta Alves")
|
||||
e.authors.seed(1, "Marta Alves", "marta-alves")
|
||||
e.authors.seed(2, "Marta Alves 2", "marta-alves-2")
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "")
|
||||
|
||||
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusCreated || body["slug"] != "marta-alves-3" {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The suffix still fits in the slug limit", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
longName := strings.Repeat("a", 100)
|
||||
e.users.users[authorEditorID] = userNamed(authorEditorID, "long", longName)
|
||||
e.authors.seed(1, longName, longName)
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "")
|
||||
|
||||
slug, _ := decodeAuthorBody(t, rec)["slug"].(string)
|
||||
if rec.Code != http.StatusCreated || utf8.RuneCountInString(slug) > 100 || !strings.HasSuffix(slug, "-2") {
|
||||
t.Errorf("status %d slug %q", rec.Code, slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A caller who already has an author is refused", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seedLinked(1, "Edith", "edith", authorEditorID)
|
||||
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""), http.StatusConflict, "user_already_linked")
|
||||
if len(e.authors.authors) != 1 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Two concurrent calls create one author", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.fail.failNextWith("Create", author.ErrUserAlreadyLinked)
|
||||
|
||||
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""), http.StatusConflict, "user_already_linked")
|
||||
})
|
||||
|
||||
t.Run("The endpoint is session-only and verified-only", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
expectAuthorBody(t, e.viaKey(t, http.MethodPost, "/v1/author/me", ""), http.StatusForbidden, sessionRefusal)
|
||||
if rec := e.as(t, apiKeyQuinnId, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusForbidden || bodyText(rec) != apperr.VerificationRequiredErrorMessage {
|
||||
t.Errorf("unverified: %d %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
if rec := e.as(t, 0, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous: %d %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
if len(e.authors.authors) != 0 || e.authors.createCalls != 0 {
|
||||
t.Errorf("an author was added")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -57,6 +57,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
|
||||
routes := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
|
||||
StartUpdateDownloadUseCase: start,
|
||||
|
||||
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
meHandler := auth.NewMeHandler(getUserUseCase)
|
||||
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
|
||||
useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
|
||||
+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, plainSites())
|
||||
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
t.Run("Create Image", func(t *testing.T) {
|
||||
input := imageDomain.ImageInput{
|
||||
|
||||
@@ -112,9 +112,10 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
return &api.UseCases{
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites()),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors()),
|
||||
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 +129,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, plainSites()),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
|
||||
|
||||
@@ -138,7 +139,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, plainSites()),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock, plainSites(), plainAuthors()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, plainSites())
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -62,27 +62,28 @@ 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, plainSites())
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
|
||||
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites()),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites()),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
|
||||
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, plainSites()),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites()),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors()),
|
||||
}
|
||||
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, "sites_updated": 0, "merged": 2}`)
|
||||
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "sites_updated": 0, "authors_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, "sites_updated": 0, "merged": 1}`)
|
||||
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 0, "sites_updated": 0, "authors_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, plainSites())
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
_, 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, plainSites())
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites())
|
||||
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants, plainSites(), plainAuthors())
|
||||
}
|
||||
seed := func(images *mockImageRepo, count int) {
|
||||
for id := uint32(1); id <= uint32(count); id++ {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) {
|
||||
t.Helper()
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
uploadDir := t.TempDir()
|
||||
|
||||
@@ -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) + `,"sites_updated":0,"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,"authors_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, plainSites())
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
now := func() time.Time { return env.clock }
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites())
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors())
|
||||
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, plainSites())
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder, plainSites())
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder, plainSites(), plainAuthors())
|
||||
}
|
||||
|
||||
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, plainSites()).Preview()
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()).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, plainSites()).Preview()
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()).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, plainSites())
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites())
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites()).Delete(testCtx, created.ID); err != nil {
|
||||
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).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, plainSites()).Delete(testCtx, 7)
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).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, plainSites()).Delete(testCtx, created.ID)
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).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, plainSites()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
|
||||
if err != nil || result.Merged != 2 {
|
||||
t.Fatalf("merge returned %+v, %v", result, err)
|
||||
|
||||
@@ -35,7 +35,7 @@ func openInMemoryDb(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) {
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.Article{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
repo := persistence.NewArticleRepository(db)
|
||||
@@ -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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
|
||||
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,12 +129,13 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
|
||||
return &api.UseCases{
|
||||
Token: token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
@@ -142,10 +143,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(), plainSites()),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites()),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -260,7 +261,7 @@ func TestArticlesBelongToASite(t *testing.T) {
|
||||
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 {
|
||||
if !reflect.DeepEqual(e.articles.articles[8], before) {
|
||||
t.Errorf("article changed: %+v", e.articles.articles[8])
|
||||
}
|
||||
e.noEvent(t)
|
||||
@@ -300,7 +301,7 @@ func TestArticlesBelongToASite(t *testing.T) {
|
||||
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 {
|
||||
if !reflect.DeepEqual(e.articles.articles[8], before) {
|
||||
t.Errorf("article changed")
|
||||
}
|
||||
e.noEvent(t)
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) {
|
||||
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"})
|
||||
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
|
||||
if err != nil || created.SiteID != 1 {
|
||||
t.Errorf("created %+v, %v", created, err)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func TestSiteUseCaseRules(t *testing.T) {
|
||||
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)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
|
||||
|
||||
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestSiteUseCaseRules(t *testing.T) {
|
||||
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)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
|
||||
missing := uint32(9)
|
||||
|
||||
_, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing})
|
||||
|
||||
@@ -274,15 +274,15 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
sites := newMockSiteRepo(p.articles)
|
||||
recorder := p.audit.recorder
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites)
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
|
||||
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.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
|
||||
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.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
|
||||
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
|
||||
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
|
||||
|
||||
@@ -197,14 +197,16 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
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)
|
||||
for _, table := range []string{"sites", "authors", "article_authors"} {
|
||||
var found int
|
||||
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&found).Error; err != nil || found != 1 {
|
||||
t.Errorf("expected the %s table to exist: %d %v", table, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isNewTableObject(name string) bool {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites"} {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_"} {
|
||||
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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestStaticImagesWithNonSeekableStorage(t *testing.T) {
|
||||
|
||||
func TestMediaSearchTreatsWildcardsLiterally(t *testing.T) {
|
||||
db, repo, _ := newMediaPersistence(t)
|
||||
if err := db.AutoMigrate(&models.Article{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
percent := createStoredImage(t, repo, "100%_done.png", pngBytes(t, 1, 1), "image/png")
|
||||
|
||||
@@ -38,6 +38,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
|
||||
container := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetUpdateStatusUseCase: rig.status,
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
|
||||
StartUpdateDownloadUseCase: rig.start,
|
||||
|
||||
@@ -857,7 +857,7 @@ func newBackupTestDatabase(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.Article{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, title := range []string{"First article", "Second article"} {
|
||||
|
||||
@@ -63,6 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
|
||||
container := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder),
|
||||
}
|
||||
|
||||
@@ -56,8 +56,9 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
|
||||
h.markers = infraUpdate.NewJSONRestartRequestStore(h.markerPath)
|
||||
h.resultFiles = infraUpdate.NewJSONResultStore(h.resultPath)
|
||||
container := &api.UseCases{
|
||||
Token: h.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
Token: h.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
|
||||
|
||||
@@ -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, plainSites())
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites())
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
|
||||
|
||||
@@ -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, plainSites())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
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, plainSites())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
_, 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, plainSites())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
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, plainSites())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
_, 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, plainSites())
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
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, plainSites())
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
err := uc.Delete(testCtx, 9)
|
||||
if !errors.Is(err, imageDomain.ErrImageNotFound) {
|
||||
|
||||
@@ -27,7 +27,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
|
||||
getUser := useCases.NewGetUserUseCase(userRepo)
|
||||
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
|
||||
handler := userhandler.NewHandler(update)
|
||||
meHandler := auth.NewMeHandler(getUser)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
@@ -120,6 +120,22 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
return decoded
|
||||
}
|
||||
|
||||
func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
decoded := decodeKeys(t, body)
|
||||
expected := append(append([]string{}, displayNameResponseKeys...), "author")
|
||||
if len(decoded) != len(expected) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body))
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, present := decoded[key]; !present {
|
||||
t.Errorf("expected key %q in body %s", key, string(body))
|
||||
}
|
||||
}
|
||||
assertNoForbiddenKeys(t, body)
|
||||
return decoded
|
||||
}
|
||||
|
||||
func decodedString(t *testing.T, decoded map[string]json.RawMessage, key string) string {
|
||||
t.Helper()
|
||||
var value string
|
||||
@@ -368,7 +384,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
decoded := assertSevenKeys(t, rec.Body.Bytes())
|
||||
decoded := assertMeKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "display_name") != "Marta Alves" || decodedString(t, decoded, "username") != "marta" {
|
||||
t.Errorf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
@@ -382,7 +398,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
decoded := assertSevenKeys(t, rec.Body.Bytes())
|
||||
decoded := assertMeKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "display_name") != "" {
|
||||
t.Errorf("expected empty display_name")
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
inviteHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
|
||||
@@ -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, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository {
|
||||
t.Helper()
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
repo := persistence.NewArticleRepository(db)
|
||||
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user