feat: Authors feature backend (and upgraded deprecated mods)

This commit is contained in:
Florian Sylvain committed 2026-10-14 17:45:00 +02:00
1 parent a11338094e
commit b34a173d83
103 files changed
+6454 -240

No files matched your search

+1 -1
View File
@@ -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() {
+6 -4
View File
@@ -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"])
}
+1 -1
View File
@@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) {
wanted []string
}{
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "media", "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"}},
+2 -1
View File
@@ -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),
}
+1 -1
View File
@@ -43,7 +43,7 @@ func newProducersEnv(t *testing.T) *producersEnv {
owner := env.users.users[apiKeyOwnerId]
owner.DisplayName = producersOwnerName
env.users.users[apiKeyOwnerId] = owner
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock, 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
+5 -5
View File
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, 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() {
+1 -1
View File
@@ -39,7 +39,7 @@ func newActivityWorld(t *testing.T) *activityWorld {
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner}
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"}
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, 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
+7 -6
View File
@@ -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),
+2 -2
View File
@@ -43,11 +43,11 @@ const (
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, 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,
)
+2 -2
View File
@@ -22,11 +22,11 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
renderer := markdown.NewConverter()
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, 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,
)
+2 -2
View File
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, 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
View File
@@ -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"}
+3 -3
View File
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, 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"})
+2 -2
View File
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, 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,
)
+168
View File
@@ -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)
}
})
}
+122
View File
@@ -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)
}
}
+482
View File
@@ -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)
}
})
}
+425
View File
@@ -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}
}
+262
View File
@@ -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.")
})
}
+539
View File
@@ -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())
}
})
}
+562
View File
@@ -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")
}
})
}
+498
View File
@@ -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,
+2 -2
View File
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, 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,
)
+1 -1
View File
@@ -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
View File
@@ -281,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+5 -4
View File
@@ -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()),
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, 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)
+8 -7
View File
@@ -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{}))
+4 -4
View File
@@ -842,7 +842,7 @@ func TestMediaMerging(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "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)
+1 -1
View File
@@ -270,7 +270,7 @@ func TestListMediaUseCasePaging(t *testing.T) {
build := func() (*mockImageRepo, *mockVariantFileRepo, *useCases.ListMediaUseCase) {
images := newMockImageRepo()
variants := newMockVariantFileRepo()
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants, 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++ {
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -591,7 +591,7 @@ func TestPurgePreview(t *testing.T) {
func TestPurgeRun(t *testing.T) {
counters := func(groups, purged, merged, articles, failed int) string {
return `{"groups_total":` + strconv.Itoa(groups) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"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) {
+2 -2
View File
@@ -31,9 +31,9 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, 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,
+6 -6
View File
@@ -32,8 +32,8 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, 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})
+4 -4
View File
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, 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)
+3 -3
View File
@@ -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()
+2 -2
View File
@@ -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,
)
+2 -2
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, 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,
)
+6 -5
View File
@@ -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),
}
}
+3 -2
View File
@@ -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)
+3 -3
View File
@@ -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})
+6 -6
View File
@@ -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)
+6 -4
View File
@@ -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
}
+2 -2
View File
@@ -23,11 +23,11 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, 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(
+1 -1
View File
@@ -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")
+1
View File
@@ -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,
+1 -1
View File
@@ -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"} {
+1
View File
@@ -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),
}
+3 -2
View File
@@ -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(
+8 -8
View File
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, 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) {
+19 -3
View File
@@ -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")
}
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -21,11 +21,11 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, 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,
)
+3 -3
View File
@@ -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,
)