diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index c978763..4b5ef1c 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -4,6 +4,7 @@ import ( domainAPIKey "RenewCMS/internal/domain/apikey" domainArticle "RenewCMS/internal/domain/article" domainAudit "RenewCMS/internal/domain/audit" + domainAuthor "RenewCMS/internal/domain/author" domainImage "RenewCMS/internal/domain/image" domainInvite "RenewCMS/internal/domain/invite" domainMail "RenewCMS/internal/domain/mail" @@ -48,6 +49,12 @@ type UseCases struct { ListArticlesUseCase *useCases.ListArticlesUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase + ListAuthorsUseCase *useCases.ListAuthorsUseCase + CreateAuthorUseCase *useCases.CreateAuthorUseCase + CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase + UpdateAuthorUseCase *useCases.UpdateAuthorUseCase + DeleteAuthorUseCase *useCases.DeleteAuthorUseCase + GetMyAuthorUseCase *useCases.GetMyAuthorUseCase ListSitesUseCase *useCases.ListSitesUseCase CreateSiteUseCase *useCases.CreateSiteUseCase UpdateSiteUseCase *useCases.UpdateSiteUseCase @@ -130,6 +137,7 @@ func (c *UseCases) RunSchedulers(ctx context.Context) { type Repositories struct { ArticleRepo domainArticle.Repository SiteRepo domainSite.Repository + AuthorRepo domainAuthor.Repository UserRepo domainUser.Repository ImageRepo domainImage.Repository VariantRepo domainImage.VariantRepository @@ -269,6 +277,7 @@ func getRepositories(connection *database.Connection, settings config.Settings) return &Repositories{ ArticleRepo: persistence.NewArticleRepository(db), SiteRepo: persistence.NewSiteRepository(db), + AuthorRepo: persistence.NewAuthorRepository(db), UserRepo: persistence.NewUserRepository(db), ImageRepo: persistence.NewImageRepository(db), VariantRepo: persistence.NewVariantRepository(db), @@ -301,9 +310,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings renderer := markdown.NewConverter() keyGenerator := hashing.NewSHA256KeyGenerator() recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default()) - mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo) + mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo) - runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo) + runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo) purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder) activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder) @@ -314,11 +323,17 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), - CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), - UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo), + UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder), + ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo), + CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder), + CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder), + UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder), + DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder), + GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo), CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), @@ -342,14 +357,14 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), - ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo), + ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), - DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo), + DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo), MergeMediaUseCase: mergeMedia, GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), - PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo), + PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo), RunPurgeUseCase: runPurge, GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now), @@ -361,7 +376,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), - ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo), + ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now), diff --git a/api/handlers/activity/activityHandler.go b/api/handlers/activity/activityHandler.go index 85fa957..7b553b2 100644 --- a/api/handlers/activity/activityHandler.go +++ b/api/handlers/activity/activityHandler.go @@ -29,7 +29,7 @@ type Handler struct { type filterQuery struct { Q string `validate:"max=200"` Actor string `validate:"max=40"` - Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites"` + Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites authors"` Outcome string `validate:"omitempty,oneof=all success failed"` Security string `validate:"omitempty,oneof=true"` Range string `validate:"omitempty,oneof=today 7d 30d all custom"` diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index ea0916c..0ecba28 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -5,6 +5,7 @@ import ( "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" domain "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/site" "RenewCMS/internal/infrastructure/useCases" "encoding/json" @@ -53,6 +54,7 @@ type articleResponse struct { BodyFormat domain.BodyFormat `json:"body_format"` IsOnline bool `json:"is_online"` SiteID uint32 `json:"site_id"` + Authors []author.Byline `json:"authors"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -66,6 +68,10 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl } body = encoded } + authors := article.Authors + if authors == nil { + authors = []author.Byline{} + } return articleResponse{ ID: article.ID, Title: article.Title, @@ -73,6 +79,7 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl BodyFormat: format, IsOnline: article.IsOnline, SiteID: article.SiteID, + Authors: authors, CreatedAt: article.CreatedAt, UpdatedAt: article.UpdatedAt, }, nil @@ -155,10 +162,12 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { return } + creatorId, _ := claims.UserId(r) createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{ - Title: localArticle.Title, - Body: localArticle.Body, - Format: format, + Title: localArticle.Title, + Body: localArticle.Body, + Format: format, + CreatedBy: creatorId, }) if err != nil { if errors.Is(err, domain.ErrInvalidTitle) { @@ -181,6 +190,10 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) { http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound) return } + if errors.Is(err, author.ErrAuthorNotFound) { + http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound) + return + } http.Error(w, "Internal server error", http.StatusInternalServerError) } @@ -221,11 +234,23 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b } type UpdateArticleBody struct { - Body *string `json:"body"` - SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"` + Body *string `json:"body"` + SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"` + AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"` } -var updatableArticleFields = []string{"body", "site_id"} +var updatableArticleFields = []string{"body", "site_id", "author_ids"} + +func hasDuplicates(ids []uint32) bool { + seen := make(map[uint32]bool, len(ids)) + for _, id := range ids { + if seen[id] { + return true + } + seen[id] = true + } + return false +} func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) { raw, err := io.ReadAll(r.Body) @@ -254,6 +279,9 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) { if err := json.Unmarshal(raw, &request); err != nil { return UpdateArticleBody{}, false } + if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) { + return UpdateArticleBody{}, false + } return request, true } @@ -281,9 +309,10 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) { } updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, useCases.UpdateArticleCommand{ - Body: request.Body, - SiteID: request.SiteID, - Format: format, + Body: request.Body, + SiteID: request.SiteID, + AuthorIDs: request.AuthorIDs, + Format: format, }) if err != nil { writeArticleErrorResponse(w, err) diff --git a/api/handlers/auth/meHandler.go b/api/handlers/auth/meHandler.go index 8fc8ff8..c2b77e9 100644 --- a/api/handlers/auth/meHandler.go +++ b/api/handlers/auth/meHandler.go @@ -4,6 +4,7 @@ import ( "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/publicuser" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" @@ -12,15 +13,34 @@ import ( ) type MeHandler struct { - GetUseCase *useCases.GetUserUseCase + GetUseCase *useCases.GetUserUseCase + GetAuthorUseCase *useCases.GetMyAuthorUseCase } -func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler { +func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase) *MeHandler { return &MeHandler{ - GetUseCase: getUser, + GetUseCase: getUser, + GetAuthorUseCase: getAuthor, } } +type meAuthor struct { + author.Byline + ArticleCount int `json:"article_count"` +} + +type meResponse struct { + publicuser.PublicUser + Author *meAuthor `json:"author"` +} + +func toMeAuthor(found *author.Author) *meAuthor { + if found == nil { + return nil + } + return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount} +} + func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { userId, ok := claims.UserId(r) if !ok { @@ -38,7 +58,13 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { return } - body, err := json.Marshal(publicuser.New(loadedUser)) + linked, err := h.GetAuthorUseCase.Get(loadedUser.ID) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked)}) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return diff --git a/api/handlers/author/authorHandler.go b/api/handlers/author/authorHandler.go new file mode 100644 index 0000000..b28367d --- /dev/null +++ b/api/handlers/author/authorHandler.go @@ -0,0 +1,389 @@ +package author + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/api/pkg/claims" + validator "RenewCMS/api/pkg/validator" + domain "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "strconv" + + "github.com/go-chi/chi/v5" + playground "github.com/go-playground/validator/v10" +) + +const ( + defaultListPage = 1 + defaultListSize = 25 + reassignParam = "reassign_to" + reassignNone = "none" +) + +type Handler struct { + ListUseCase *useCases.ListAuthorsUseCase + CreateUseCase *useCases.CreateAuthorUseCase + CreateMyUseCase *useCases.CreateMyAuthorUseCase + UpdateUseCase *useCases.UpdateAuthorUseCase + DeleteUseCase *useCases.DeleteAuthorUseCase + GetUserUseCase *useCases.GetUserUseCase +} + +func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler { + return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser} +} + +type socialLinkRequest struct { + Type string `json:"type" validate:"socialtype"` + URL string `json:"url"` +} + +func toLinks(requests []socialLinkRequest) []domain.SocialLink { + links := make([]domain.SocialLink, 0, len(requests)) + for _, request := range requests { + links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL}) + } + return links +} + +type PostAuthor struct { + Name string `json:"name" validate:"authorname"` + Slug string `json:"slug" validate:"omitempty,authorslug"` + Bio string `json:"bio"` + AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"` + Website *string `json:"website"` + SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"` + UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"` +} + +type optional[T any] struct { + Set bool + Value *T +} + +func (o *optional[T]) UnmarshalJSON(data []byte) error { + o.Set = true + if string(data) == "null" { + o.Value = nil + return nil + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return err + } + o.Value = &value + return nil +} + +type PatchAuthor struct { + Name *string `json:"name" validate:"authorname"` + Slug *string `json:"slug" validate:"authorslug"` + Bio *string `json:"bio"` + AvatarID optional[uint32] `json:"avatar_id"` + Website optional[string] `json:"website"` + SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"` + UserID optional[uint32] `json:"user_id"` +} + +func (p PatchAuthor) toPatch() domain.Patch { + patch := domain.Patch{ + Name: p.Name, + Slug: p.Slug, + Bio: p.Bio, + AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value}, + Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value}, + UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value}, + } + if p.SocialLinks != nil { + links := toLinks(*p.SocialLinks) + patch.SocialLinks = &links + } + return patch +} + +var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"} + +var nullableFields = []string{"avatar_id", "website", "user_id"} + +func readPatchAuthor(r *http.Request) (PatchAuthor, bool) { + raw, err := io.ReadAll(r.Body) + if err != nil { + return PatchAuthor{}, false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return PatchAuthor{}, false + } + known := 0 + for _, name := range requiredNonNullFields { + value, present := fields[name] + if !present { + continue + } + if string(value) == "null" { + return PatchAuthor{}, false + } + known++ + } + for _, name := range nullableFields { + if _, present := fields[name]; present { + known++ + } + } + if known == 0 { + return PatchAuthor{}, false + } + var request PatchAuthor + if err := json.Unmarshal(raw, &request); err != nil { + return PatchAuthor{}, false + } + return request, true +} + +func parseAuthorId(r *http.Request) (uint32, error) { + id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) + return uint32(id), err +} + +func presentAuthor(a domain.Author) domain.Author { + if a.SocialLinks == nil { + a.SocialLinks = []domain.SocialLink{} + } + return a +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + body, err := json.Marshal(payload) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + if status != http.StatusOK { + w.WriteHeader(status) + } + if _, err := w.Write(body); err != nil { + log.Printf("Failed to write response: %v", err) + } +} + +func writeAuthorError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, domain.ErrAuthorNotFound): + http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound) + case errors.Is(err, domain.ErrSlugTaken): + http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict) + case errors.Is(err, domain.ErrAuthorHasArticles): + http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict) + case errors.Is(err, domain.ErrUserAlreadyLinked): + http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict) + case errors.Is(err, domain.ErrAuthorAlreadyLinked): + http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict) + case errors.Is(err, user.ErrUserNotFound): + http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound) + case errors.Is(err, domain.ErrAuthorRights): + http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden) + case errors.Is(err, domain.ErrBioTooLong): + http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity) + case errors.Is(err, domain.ErrInvalidURL): + http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity) + case errors.Is(err, domain.ErrTooManyLinks): + http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity) + case errors.Is(err, domain.ErrInvalidSocialType): + http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest) + case errors.Is(err, domain.ErrInvalidSlug): + http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest) + case errors.Is(err, domain.ErrInvalidReassign): + http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest) + case errors.Is(err, image.ErrImageNotFound): + http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func writeValidationError(w http.ResponseWriter, err error) { + var failures playground.ValidationErrors + if errors.As(err, &failures) { + for _, failure := range failures { + if failure.Tag() == validator.AuthorSlugTag { + http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest) + return + } + } + } + http.Error(w, err.Error(), http.StatusBadRequest) +} + +func (h *Handler) currentUser(r *http.Request) (user.User, bool) { + userId, ok := claims.UserId(r) + if !ok { + return user.User{}, false + } + current, err := h.GetUserUseCase.GetUser(userId) + if err != nil { + return user.User{}, false + } + return current, true +} + +type listQuery struct { + Page int `validate:"gte=1"` + Size int `validate:"oneof=5 25 50 100"` + Q string `validate:"max=200"` +} + +type listResponse struct { + Items []domain.Author `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +func intQueryParam(values url.Values, name string, fallback int) (int, error) { + if !values.Has(name) { + return fallback, nil + } + return strconv.Atoi(values.Get(name)) +} + +func readListQuery(values url.Values) (listQuery, error) { + page, err := intQueryParam(values, "page", defaultListPage) + if err != nil { + return listQuery{}, err + } + size, err := intQueryParam(values, "size", defaultListSize) + if err != nil { + return listQuery{}, err + } + return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + query, err := readListQuery(r.URL.Query()) + if err != nil || validator.Validate.Struct(query) != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + + current, _ := h.currentUser(r) + page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role}) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + items := make([]domain.Author, 0, len(page.Items)) + for _, item := range page.Items { + items = append(items, presentAuthor(item)) + } + writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size}) +} + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var request PostAuthor + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + if err := validator.Validate.Struct(request); err != nil { + writeValidationError(w, err) + return + } + + created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{ + Name: request.Name, + Slug: request.Slug, + Bio: request.Bio, + AvatarID: request.AvatarID, + Website: request.Website, + SocialLinks: toLinks(request.SocialLinks), + UserID: request.UserID, + }) + if err != nil { + writeAuthorError(w, err) + return + } + writeJSON(w, http.StatusCreated, presentAuthor(created)) +} + +func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) { + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + created, err := h.CreateMyUseCase.Create(r.Context(), userId) + if err != nil { + writeAuthorError(w, err) + return + } + writeJSON(w, http.StatusCreated, presentAuthor(created)) +} + +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + id, err := parseAuthorId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + request, ok := readPatchAuthor(r) + if !ok { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + if err := validator.Validate.Struct(request); err != nil { + writeValidationError(w, err) + return + } + + current, ok := h.currentUser(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch()) + if err != nil { + writeAuthorError(w, err) + return + } + writeJSON(w, http.StatusOK, presentAuthor(updated)) +} + +func readReassignment(r *http.Request) useCases.Reassignment { + query := r.URL.Query() + if !query.Has(reassignParam) { + return useCases.Reassignment{Mode: useCases.ReassignAbsent} + } + value := query.Get(reassignParam) + if value == reassignNone { + return useCases.Reassignment{Mode: useCases.ReassignNone} + } + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return useCases.Reassignment{Mode: useCases.ReassignMalformed} + } + return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)} +} + +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := parseAuthorId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil { + writeAuthorError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/api/handlers/media/mediaHandler.go b/api/handlers/media/mediaHandler.go index c0be9e6..3e1d016 100644 --- a/api/handlers/media/mediaHandler.go +++ b/api/handlers/media/mediaHandler.go @@ -98,6 +98,7 @@ type usageResponse struct { type mergeResponse struct { ArticlesUpdated int `json:"articles_updated"` SitesUpdated int `json:"sites_updated"` + AuthorsUpdated int `json:"authors_updated"` Merged int `json:"merged"` } @@ -353,7 +354,7 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, Merged: result.Merged}) + writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, AuthorsUpdated: result.AuthorsUpdated, Merged: result.Merged}) } func writeMergeError(w http.ResponseWriter, err error) { diff --git a/api/handlers/media/purgeHandler.go b/api/handlers/media/purgeHandler.go index 4577a07..72eba9e 100644 --- a/api/handlers/media/purgeHandler.go +++ b/api/handlers/media/purgeHandler.go @@ -54,6 +54,7 @@ type purgeRunResponse struct { Merged int `json:"merged"` ArticlesUpdated int `json:"articles_updated"` SitesUpdated int `json:"sites_updated"` + AuthorsUpdated int `json:"authors_updated"` FailedGroups int `json:"failed_groups"` } @@ -166,6 +167,7 @@ func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) { Merged: result.Merged, ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, + AuthorsUpdated: result.AuthorsUpdated, FailedGroups: result.FailedGroups, }) } diff --git a/api/middleware/apiKey.go b/api/middleware/apiKey.go index e2c222a..1f4596b 100644 --- a/api/middleware/apiKey.go +++ b/api/middleware/apiKey.go @@ -12,7 +12,7 @@ import ( "time" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" ) const bearerScheme = "bearer " diff --git a/api/middleware/requireSignedIn.go b/api/middleware/requireSignedIn.go index 66390c9..c5af9ee 100644 --- a/api/middleware/requireSignedIn.go +++ b/api/middleware/requireSignedIn.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" ) func RequireSignedIn(next http.Handler) http.Handler { diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index addf6da..8b8cb8e 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -67,3 +67,17 @@ const InvalidLocaleErrorMessage = "invalid_locale" const SiteImageNotFoundErrorMessage = "image_not_found" const UseSetDefaultErrorMessage = "use_set_default" const InvalidReassignErrorMessage = "invalid_reassign" +const InvalidSocialTypeErrorMessage = "invalid_social_type" + +const AuthorNotFoundErrorMessage = "author_not_found" +const AuthorSlugTakenErrorMessage = "author_slug_taken" +const AuthorHasArticlesErrorMessage = "author_has_articles" +const UserAlreadyLinkedErrorMessage = "user_already_linked" +const AuthorAlreadyLinkedErrorMessage = "author_already_linked" +const UserNotFoundErrorMessage = "user_not_found" +const AuthorRightsErrorMessage = "author_rights" +const BioTooLongErrorMessage = "bio_too_long" +const InvalidURLErrorMessage = "invalid_url" +const TooManyLinksErrorMessage = "too_many_links" +const InvalidSlugErrorMessage = "invalid_slug" +const AuthorImageNotFoundErrorMessage = "image_not_found" diff --git a/api/pkg/validator/validator.go b/api/pkg/validator/validator.go index 8c0adc2..1a5c4ec 100644 --- a/api/pkg/validator/validator.go +++ b/api/pkg/validator/validator.go @@ -1,8 +1,10 @@ package validator import ( + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" + "reflect" "regexp" "github.com/go-playground/validator/v10" @@ -12,6 +14,9 @@ const inviteEmailTag = "inviteemail" const variantNameTag = "variantname" const siteKeyTag = "sitekey" const siteNameTag = "sitename" +const AuthorNameTag = "authorname" +const AuthorSlugTag = "authorslug" +const SocialTypeTag = "socialtype" var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`) @@ -23,6 +28,25 @@ func InitValidator() { _ = Validate.RegisterValidation(variantNameTag, isVariantName) _ = Validate.RegisterValidation(siteKeyTag, isSiteKey) _ = Validate.RegisterValidation(siteNameTag, isSiteName) + _ = Validate.RegisterValidation(AuthorNameTag, isAuthorName, true) + _ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true) + _ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true) +} + +func isAbsent(field validator.FieldLevel) bool { + return field.Field().Kind() == reflect.Ptr && field.Field().IsNil() +} + +func isAuthorName(field validator.FieldLevel) bool { + return isAbsent(field) || author.IsValidName(field.Field().String()) +} + +func isSocialType(field validator.FieldLevel) bool { + return author.IsValidSocialType(field.Field().String()) +} + +func isAuthorSlug(field validator.FieldLevel) bool { + return isAbsent(field) || author.IsValidSlug(field.Field().String()) } func isSiteKey(field validator.FieldLevel) bool { diff --git a/api/router.go b/api/router.go index 4587241..4b4dbd3 100644 --- a/api/router.go +++ b/api/router.go @@ -5,6 +5,7 @@ import ( "RenewCMS/api/handlers/apikey" "RenewCMS/api/handlers/article" "RenewCMS/api/handlers/auth" + "RenewCMS/api/handlers/author" "RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/media" @@ -80,7 +81,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) - meHandler := auth.NewMeHandler(container.GetUserUseCase) + meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase) passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction) @@ -204,6 +205,38 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { return r } +func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler { + r := chi.NewRouter() + + authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase) + + r.Group(func(r chi.Router) { + r.Use(jwtauth.Verifier(container.Token)) + r.Use(container.apiKeyAuth()) + r.Use(jwtauth.Authenticator(container.Token)) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.Get("/", authorHandler.List) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireSession) + r.Use(jwtauth.Verifier(container.Token)) + r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) + r.Use(middleware.RequireSignedIn) + r.Use(jwtauth.Authenticator(container.Token)) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.Post("/me", authorHandler.CreateMine) + r.Patch("/{id}", authorHandler.Update) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) + r.Post("/", authorHandler.Create) + r.Delete("/{id}", authorHandler.Delete) + }) + }) + return r +} + func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler { r := chi.NewRouter() @@ -352,6 +385,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies)) r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies)) + r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies)) r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies)) r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies)) r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies)) diff --git a/docs/api.md b/docs/api.md index 8628812..330877d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,17 +18,18 @@ for example `https://cms.example/v1/article/`. - A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or `/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE` - under `/v1/site`) are session only too. + under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. -## Routes a key can call (25) +## Routes a key can call (26) | Group | Routes | | --- | --- | | Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | -| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me | +| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) | | Settings | GET /v1/settings | | Sites | GET /v1/site/ | +| Authors | GET /v1/author/ | ## Activity log The activity routes are session only: a key answers 403 "This action needs a real session." and a @@ -42,7 +43,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU | `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. | Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`, -`api_keys`, `auth`, `system`, `sites`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` +`api_keys`, `auth`, `system`, `sites`, `authors`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from 1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`). @@ -68,11 +69,12 @@ never changes the response or the change itself. | `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` | +| `authors` | `author.create`, `author.edit`, `author.delete` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | -Target types: `article`, `site`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`, +Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`, `api_key` and `release` (id and name are the tag). -Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names), with the +Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A change that leaves a value as it was (same body, same publish state, same file name, same display name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry @@ -157,6 +159,54 @@ Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` /v1/media/purge` repoint the sites of the merged copies to the kept image and report it as `sites_updated` next to `articles_updated`. +## Authors + +An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id, +website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins +only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches +`^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when +omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has +at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null` +or an `https` URL with a host (an empty string means none, at most 2048 characters), +`social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`, +`mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an +article is allowed and never blocks publishing. + +| Route | Who | Purpose | +| --- | --- | --- | +| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`; a bad query is 400 with the body-mismatch message. | +| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. | +| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. | +| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. | +| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. | + +The role checks use the existing bodies: 403 "Only owners and admins can do this." for `POST /` and +`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author +that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key. +Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`, +409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409 +`author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number, +negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422 +`too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field +is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other +validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a +reason other than a missing target or an invalid value are recorded as failed events. + +Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`, +`name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it +is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes +`author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404 +`author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and +`0` is 400 with the validator text. Absent means unchanged. A new article is credited to the +creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus +`author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`, +`social_links`, `article_count`) or `null`. + +An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in` +and keeps the image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles +only. Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge` +repoint the avatars of the merged copies and report it as `authors_updated`. + ## Image variants Owners and admins manage named variants of the stored images. These routes are session only (no API key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do diff --git a/docs/api/articles.md b/docs/api/articles.md index 93fe5d4..64eb4e8 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -2,7 +2,7 @@ Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML. -`PATCH /v1/article/{id}` takes `body` and `site_id`, and nothing else. The request must contain at least one of them, and `null` for either answers `400`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body. +`PATCH /v1/article/{id}` takes `body`, `site_id` and `author_ids` (the whole ordered list of author ids, see `docs/api.md`), and nothing else. The request must contain at least one of them, and `null` for either answers `400`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body. ## Routes that honour `format` @@ -70,6 +70,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send ` "body_format": "markdown", "is_online": true, "site_id": 1, + "authors": [], "created_at": "2026-03-12T09:14:00Z", "updated_at": "2026-09-10T07:00:00Z" } diff --git a/docs/architecture.md b/docs/architecture.md index c93480f..231a8d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,6 +75,12 @@ Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/a The use cases `ListSites`, `CreateSite`, `UpdateSite`, `SetDefaultSite` and `DeleteSite` live in `internal/infrastructure/useCases`; the article, media and purge use cases take the site repository as a required constructor argument, wired by the composition root (new articles take the default site, `UpdateArticle` moves an article and records `site` with site names, the media use cases count, clear and repoint default social images). `api/handlers/site` serves `/v1/site`; reads accept a session or an API key, writes are session only behind `RequireOwner`. `database.Migrate(db, publicURL)` registers the model, seeds one default site from `PUBLIC_URL` when the table is empty and backfills `articles.site_id`. +### Authors + +`internal/domain/author` holds the `Author` entity (name, unique slug, bio, optional avatar, optional website, social links, the unique optional `user_id`, `is_linked`, the computed `article_count` and a `user_email` that only owner and admin responses carry), the public `Byline` shape embedded in articles, the `Patch` type, the slug generation (a hand-written Latin diacritic folding table, no external dependency), the value rules (`ValidateBio`, `ValidateURL`, `ValidateLinks`, `IsValidSlug`, `IsValidName`), the `Err...` sentinels (`ErrAuthorNotFound`, `ErrSlugTaken`, `ErrAuthorHasArticles`, `ErrUserAlreadyLinked`, `ErrAuthorAlreadyLinked`, `ErrAuthorRights`, `ErrBioTooLong`, `ErrInvalidURL`, `ErrTooManyLinks`, `ErrInvalidSlug`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.AuthorRepository` implements it over `models.Author` (`authors`, unique `slug` and unique nullable `user_id`) and the ordered join `models.ArticleAuthor` (`article_authors`, unique `(article_id, author_id)` and a `position`); uniqueness is checked inside the transaction and re-explained after a failed write, so a race still answers a domain sentinel, and `DeleteMovingCredits` moves the credits (keeping the position, never duplicating) and deletes the author in one transaction. `ArticleRepository` attaches the ordered `Byline`s to the articles it returns, writes `author_ids` in the article update transaction and removes the credits when an article is deleted. + +The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `DeleteAuthor` and `GetMyAuthor` live in `internal/infrastructure/useCases`; `CreateArticle` credits the creator's author, `UpdateArticle` validates and records `author_ids`, and the media, purge and activity use cases take the author repository as a required constructor argument (avatars count as media usage, are cleared on delete and repointed on merge and purge). `api/handlers/author` serves `/v1/author`: the list accepts a session or an API key, every write is session only, `POST /` and `DELETE` sit behind `RequireOwnerOrAdmin`, and the edit rights (owner or admin, or the caller's own linked author) are decided by `UpdateAuthor` from the caller the handler passes in. `GET /v1/auth/me` adds the linked author through `GetMyAuthor`. + ### Image variants `internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`. diff --git a/features/authors_backend.feature b/features/authors_backend.feature new file mode 100644 index 0000000..e8ead0c --- /dev/null +++ b/features/authors_backend.feature @@ -0,0 +1,863 @@ +Feature: Authors backend, public bylines that articles credit in order + + As an owner or admin of a RenewCMS installation + I want to manage authors and credit them on articles in a chosen order + So that readers see who wrote an article, whether or not the writer has an account + + Background: + Given the spec of record is docs/design/screens/authors.md, docs/design/screens/editor.md (site and authors), docs/design/screens/profile.md, docs/design/states.md (author rows), docs/design/routes.md and docs/design/sites-authors-slices.md + And this slice is backend only: no SPA, no change to docs/design + And an Author has id (uint32), name, slug, bio, avatar_id (nullable media id), website (nullable), social_links, user_id (nullable and unique), is_linked and article_count, in snake_case JSON + And an article has zero or more authors in a stored order through an ordered article_authors join; the first is the primary byline + And zero authors on an article is allowed and never blocks publishing + And the routes live under /v1/author, mounted inside the JWT group: GET /, POST /, POST /me, PATCH /{id}, DELETE /{id}?reassign_to={id|none} + And GET /v1/author/ accepts a session or an API key from a verified account; every write, POST /me included, is session-only + And POST / and DELETE /{id} go through RequireOwnerOrAdmin with its existing body "Only owners and admins can do this." + And PATCH /{id} is allowed to owner and admin on any author, and to any verified account on the author linked to itself, and otherwise answers 403 with the body "author_rights" + And the only roles defined today are "owner" and "admin", so a role-without-author-rights is exercised with a verified account whose role is any other value + And error responses are plain text from http.Error whose body is exactly the code token: author_not_found, author_slug_taken, author_has_articles, user_already_linked, author_already_linked, user_not_found, author_rights, bio_too_long, invalid_url, too_many_links, invalid_slug, invalid_reassign, image_not_found + And bio_too_long, invalid_url and too_many_links answer 422; other validate-tag failures answer 400 with the validator error text, a body that cannot be decoded answers 400 with apperr.BodyErrorMessage, and a malformed id answers 400 with apperr.IdUint32ErrorMessage + And the name has 1 to 100 characters after trimming, the slug matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 1 to 100 characters, the bio has at most 280 characters counted in runes + And website and every social link URL must be https with a host, at most 2048 characters; an empty or null website means none + And social_links holds at most 5 items of {"type", "url"} where type is one of x, linkedin, github, mastodon, custom + And an author has user_email in its JSON only for owner and admin; it is absent for every other caller + And an author inside an article has only id, name, slug, bio, avatar_id, website and social_links + And request validation goes through validate tags and validator.Validate.Struct + And handlers reach authors only through use cases and domain interfaces, and no comment is added anywhere + + Rule: Reading authors + + Scenario: The list is newest first with article counts + Given the authors "Léa Fontaine" (id 1), "Inès Duarte" (id 2) and "Camille Roche" (id 3) + And "Inès Duarte" is credited on 9 articles and "Léa Fontaine" on none + When a verified account sends GET /v1/author/ + Then the status is 200 + And the body is {"items": [...], "total": 3, "page": 1, "size": 25} + And the items are "Camille Roche", "Inès Duarte", "Léa Fontaine" in that order + And every item carries article_count + + Scenario Outline: Who can read the list + Given at least one author exists + When GET /v1/author/ is sent with + Then the status is + + Examples: + | credentials | status | + | the owner's jwt cookie | 200 | + | a verified admin's jwt cookie | 200 | + | a verified account with another role | 200 | + | a valid API key | 200 | + | an unverified account's jwt cookie | 403 | + | no credentials | 401 | + + Scenario Outline: Search matches the name or the slug + Given the authors "Camille Roche" (slug "camille-roche") and "Zoe Lambert" (slug "zl") + When a verified account sends GET /v1/author/?q= + Then the items are + And total is + + Examples: + | q | names | total | + | camille | "Camille Roche" | 1 | + | ROCHE | "Camille Roche" | 1 | + | zl | "Zoe Lambert" | 1 | + | zoe | "Zoe Lambert" | 1 | + | nobody | none | 0 | + + Scenario Outline: Pagination follows the article list + Given 30 authors + When a verified account sends GET /v1/author/? + Then the status is + And + + Examples: + | params | status | result | + | size=25&page=2 | 200 | 5 items, total 30, page 2, size 25 | + | size=5 | 200 | 5 items, total 30, page 1, size 5 | + | page=3&size=100 | 200 | 0 items, total 30 | + | size=30 | 400 | the body is apperr.BodyErrorMessage | + | page=0 | 400 | the body is apperr.BodyErrorMessage | + | page=abc | 400 | the body is apperr.BodyErrorMessage | + + Scenario: The account email is shown to owner and admin only + Given the author "Inès Duarte" is linked to the user ines.duarte@renew.example + When the owner sends GET /v1/author/ + Then "Inès Duarte" has is_linked true, user_id set and user_email "ines.duarte@renew.example" + When a verified account with another role sends GET /v1/author/ + Then "Inès Duarte" has is_linked true + And no "user_email" key appears anywhere in the body + And the body does not contain "ines.duarte@renew.example" + + Scenario: A guest author is not linked + Given the author "Camille Roche" has no user + When a verified account sends GET /v1/author/ + Then "Camille Roche" has is_linked false, user_id null and no user_email + + Scenario: An author without avatar or website returns nulls + When a verified account reads an author with no avatar and no website + Then avatar_id is null, website is null, bio is "" and social_links is [] + + Rule: Creating an author is owner or admin only + + Scenario Outline: An owner or admin creates an author + When sends POST /v1/author/ with {"name": "Camille Roche", "slug": "camille-roche", "bio": "Writer.", "website": "https://example.com", "social_links": [{"type": "x", "url": "https://x.com/camille"}]} + Then the status is 201 + And the body is the author with a new id, article_count 0, is_linked false and the given values + And the author is stored + + Examples: + | credentials | + | the owner's jwt cookie | + | a verified admin's jwt cookie | + + Scenario: Only the name is required + When the owner sends POST /v1/author/ with {"name": "Zoe Lambert"} + Then the status is 201 + And the slug is "zoe-lambert", bio is "", avatar_id, website and user_id are null and social_links is [] + + Scenario Outline: The slug is generated from the name + When the owner sends POST /v1/author/ with {"name": ""} + Then the slug is "" + + Examples: + | name | slug | + | Camille Roche | camille-roche | + | Inès Duarte | ines-duarte | + | Dr. O'Neil Smith | dr-o-neil-smith | + | !!! | author | + + Scenario: A generated slug that is taken is refused + Given the author with slug "camille-roche" exists + When the owner sends POST /v1/author/ with {"name": "Camille Roche"} + Then the status is 409 + And the body is "author_slug_taken" + And no author is added + + Scenario: A taken slug is refused + Given the author with slug "camille-roche" exists + When the owner sends POST /v1/author/ with {"name": "Other", "slug": "camille-roche"} + Then the status is 409 + And the body is "author_slug_taken" + And no author is added + + Scenario: The slug is unique even if the repository reports a race + Given the repository reports a unique violation on the slug + When the owner sends POST /v1/author/ with a free-looking slug + Then the status is 409 with body "author_slug_taken" + + Scenario Outline: Fields are validated + When the owner sends POST /v1/author/ with + Then the status is + And the body is "" + And no author is added + + Examples: + | field | value | status | body | + | name | (empty) | 400 | the validator error text | + | name | " " | 400 | the validator error text | + | name | 101 characters | 400 | the validator error text | + | slug | "Camille" | 400 | invalid_slug | + | slug | "camille_roche" | 400 | invalid_slug | + | slug | "-camille" | 400 | invalid_slug | + | slug | 101 characters | 400 | invalid_slug | + | bio | 281 characters | 422 | bio_too_long | + | website | "http://example.com" | 422 | invalid_url | + | website | "example.com" | 422 | invalid_url | + | website | "https://" | 422 | invalid_url | + | social_links | 6 links | 422 | too_many_links | + | social_links | [{"type": "x", "url": "http://x.com/a"}] | 422 | invalid_url | + | social_links | [{"type": "facebook", "url": "https://f.com/a"}] | 400 | the validator error text | + | avatar_id | 999 (no such image) | 400 | image_not_found | + + Scenario Outline: Boundaries are accepted + When the owner sends POST /v1/author/ with + Then the status is 201 + + Examples: + | field | value | + | name | 100 characters | + | slug | 100 characters | + | bio | 280 characters | + | bio | 280 multi-byte characters | + | social_links | 5 links | + | social_links | 2 links of type "custom" | + | website | "" | + + Scenario: A malformed body is refused + When the owner sends POST /v1/author/ with the body "{not json" + Then the status is 400 + And the body is apperr.BodyErrorMessage + And no author is added + + Scenario: The role is checked before the body + When an account with another role sends POST /v1/author/ with the body "{not json" + Then the status is 403 with body "Only owners and admins can do this." + + Scenario: An author can be created linked to a user + Given the user 3 has no author + When the owner sends POST /v1/author/ with {"name": "Marta Alves", "user_id": 3} + Then the status is 201 + And the author has user_id 3 and is_linked true + + Scenario Outline: An author cannot be created with an unusable user + When the owner sends POST /v1/author/ with {"name": "X", "user_id": } + Then the status is + And the body is "" + And no author is added + + Examples: + | user_id | status | body | + | 999 (no such user) | 404 | user_not_found | + | 3 (already has author) | 409 | user_already_linked | + + Rule: The caller creates their own author + + Scenario Outline: The author is named from the display name or the username + Given the verified account has display name "" and username "" and no author + When it sends POST /v1/author/me + Then the status is 201 + And the author name is "" and the slug is "" + And the author has user_id of the caller and is_linked true + + Examples: + | display | username | name | slug | + | Marta Alves | marta | Marta Alves | marta-alves | + | (empty) | marta | marta | marta | + + Scenario Outline: Any verified role may do it + When sends POST /v1/author/me + Then the status is 201 + + Examples: + | credentials | + | the owner's jwt cookie | + | a verified admin's jwt cookie | + | a verified account with another role | + + Scenario: A taken slug is de-duplicated silently + Given the author with slug "marta-alves" exists and "marta-alves-2" too + When "Marta Alves" sends POST /v1/author/me + Then the status is 201 + And the slug is "marta-alves-3" + And no error is returned + + Scenario: The suffix still fits in the slug limit + Given a display name whose slug is 100 characters and that slug is taken + When the caller sends POST /v1/author/me + Then the status is 201 + And the slug is at most 100 characters and ends with "-2" + + Scenario: A caller who already has an author is refused + Given the caller is linked to an author + When the caller sends POST /v1/author/me + Then the status is 409 + And the body is "user_already_linked" + And no author is added + + Scenario: Two concurrent calls create one author + Given the repository reports a unique violation on user_id + When the caller sends POST /v1/author/me + Then the status is 409 with body "user_already_linked" + + Scenario Outline: The endpoint is session-only and verified-only + When POST /v1/author/me is sent with + Then the status is + And no author is added + + Examples: + | credentials | status | body | + | a valid API key | 403 | This action needs a real session. | + | an unverified account's jwt cookie | 403 | the existing verification message | + | no credentials | 401 | the existing unauthorized message | + + Rule: Editing an author + + Scenario: The owner edits any author + Given the author 2 "Camille Roche" with slug "camille-roche" + When the owner sends PATCH /v1/author/2 with {"name": "Camille R.", "slug": "camille-r", "bio": "New.", "website": "https://camille.example", "social_links": [{"type": "github", "url": "https://github.com/camille"}]} + Then the status is 200 + And the body is the author with the new values + + Scenario: Absent fields are untouched + Given the author 2 has a bio, a website and an avatar + When the owner sends PATCH /v1/author/2 with {"name": "Camille R."} + Then only the name changed + + Scenario Outline: Nullable fields are cleared + Given the author 2 has an avatar 5, a website and 2 social links + When the owner sends PATCH /v1/author/2 with + Then the status is 200 + And + + Examples: + | body | result | + | {"avatar_id": null} | avatar_id is null | + | {"website": null} | website is null | + | {"website": ""} | website is null | + | {"social_links": []} | social_links is [] | + | {"bio": ""} | bio is "" | + + Scenario: The avatar is set to an existing image + Given the image 5 exists + When the owner sends PATCH /v1/author/2 with {"avatar_id": 5} + Then the author 2 has avatar_id 5 + + Scenario: Social links are replaced as a whole + Given the author 2 has 3 social links + When the owner sends PATCH /v1/author/2 with {"social_links": [{"type": "x", "url": "https://x.com/a"}]} + Then the author 2 has exactly that one link + + Scenario Outline: Editing is validated like creation + When the owner sends PATCH /v1/author/2 with + Then the status is + And the body is "" + And the author 2 is unchanged + + Examples: + | field | value | status | body | + | name | (empty) | 400 | the validator error text | + | slug | "Bad Slug" | 400 | invalid_slug | + | bio | 281 characters | 422 | bio_too_long | + | website | "http://example.com" | 422 | invalid_url | + | social_links | 6 links | 422 | too_many_links | + | avatar_id | 999 | 400 | image_not_found | + + Scenario: Keeping the own slug is not a collision + When the owner sends PATCH /v1/author/2 with {"slug": "camille-roche", "bio": "x"} and the slug is already that author's + Then the status is 200 + + Scenario: Taking another author's slug is refused + Given the author 3 has slug "zoe-lambert" + When the owner sends PATCH /v1/author/2 with {"slug": "zoe-lambert"} + Then the status is 409 + And the body is "author_slug_taken" + And the author 2 is unchanged + + Scenario Outline: An empty or unknown update is refused + When the owner sends PATCH /v1/author/2 with + Then the status is 400 + And the body is apperr.BodyErrorMessage + And the author 2 is unchanged + + Examples: + | body | + | {} | + | {"colour": "x"} | + | {"name": null} | + + Scenario: A missing author is not found + When the owner sends PATCH /v1/author/999 with {"name": "X"} + Then the status is 404 + And the body is "author_not_found" + + Scenario: A malformed id is refused + When the owner sends PATCH /v1/author/abc with {"name": "X"} + Then the status is 400 + And the body is apperr.IdUint32ErrorMessage + + Scenario: A no-op edit records nothing + When the owner sends PATCH /v1/author/2 with the values it already has + Then the status is 200 + And no activity event is recorded + + Scenario Outline: A role without author rights edits only its own author + Given the verified account with another role is linked to the author 4 + When it sends PATCH /v1/author/ with {"bio": "Updated."} + Then the status is + And the body is "" + + Examples: + | id | status | body | + | 4 | 200 | the author | + | 2 | 403 | author_rights | + | 999 | 404 | author_not_found | + + Scenario Outline: Own author edits include name, slug and avatar + Given the verified account with another role is linked to the author 4 + When it sends PATCH /v1/author/4 with + Then the status is 200 + + Examples: + | body | + | {"name": "Marta A."} | + | {"slug": "marta-a"} | + | {"avatar_id": 5} | + | {"social_links": []} | + + Scenario: A role without author rights cannot change the link + Given the verified account with another role is linked to the author 4 + When it sends PATCH /v1/author/4 with {"user_id": null} + Then the status is 403 + And the body is "author_rights" + And the author 4 is still linked + + Scenario: An API key cannot edit an author + When PATCH /v1/author/2 is sent with a valid API key + Then the status is 403 with body "This action needs a real session." + + Rule: Linking and unlinking a user + + Scenario: The owner links a user to a guest author + Given the author 2 is a guest and the user 3 has no author + When the owner sends PATCH /v1/author/2 with {"user_id": 3} + Then the status is 200 + And the author 2 has user_id 3, is_linked true and user_email of the user 3 + + Scenario: A user cannot have two authors + Given the user 3 is linked to the author 4 + When the owner sends PATCH /v1/author/2 with {"user_id": 3} + Then the status is 409 + And the body is "user_already_linked" + And the author 2 is unchanged + + Scenario: A linked author must be unlinked before it can be relinked + Given the author 2 is linked to the user 3 and the user 5 has no author + When the owner sends PATCH /v1/author/2 with {"user_id": 5} + Then the status is 409 + And the body is "author_already_linked" + + Scenario: Linking the user it already has is a no-op + Given the author 2 is linked to the user 3 + When the owner sends PATCH /v1/author/2 with {"user_id": 3} + Then the status is 200 + And no activity event is recorded + + Scenario: An unknown user cannot be linked + When the owner sends PATCH /v1/author/2 with {"user_id": 999} + Then the status is 404 + And the body is "user_not_found" + + Scenario: The owner unlinks a user + Given the author 2 is linked to the user 3 + When the owner sends PATCH /v1/author/2 with {"user_id": null} + Then the status is 200 + And the author 2 has user_id null, is_linked false and no user_email + And the user 3 can create or be linked to an author again + And the author 2 stays credited on its articles + + Scenario: Unlinking a guest author is a no-op + When the owner sends PATCH /v1/author/2 with {"user_id": null} on a guest author + Then the status is 200 + And no activity event is recorded + + Scenario: A new article by the unlinked user no longer starts with the author + Given the user 3 was unlinked from the author 2 + When the user 3 creates an article + Then the article has no authors + + Rule: Deleting an author + + Scenario: An author without articles is deleted + Given the author 2 credits no article + When the owner sends DELETE /v1/author/2 + Then the status is 204 + And the author 2 no longer exists + + Scenario: An author with articles needs a destination + Given the author 2 is credited on 3 articles + When the owner sends DELETE /v1/author/2 + Then the status is 409 + And the body is "author_has_articles" + And the author and the 3 credits are unchanged + + Scenario: The articles are reassigned to another author + Given the author 2 is credited on the articles 10, 11 and 12 and the author 3 exists + When the owner sends DELETE /v1/author/2?reassign_to=3 + Then the status is 204 + And the articles 10, 11 and 12 credit the author 3 + And the author 2 no longer exists + + Scenario: The reassigned author takes the position of the deleted one + Given the article 10 credits the authors 5, 2, 6 in that order + When the owner deletes the author 2 with reassign_to=3 + Then the article 10 credits 5, 3, 6 in that order + + Scenario: A reassignment never duplicates an author on an article + Given the article 10 credits the authors 2 then 3 + When the owner deletes the author 2 with reassign_to=3 + Then the article 10 credits only the author 3 + + Scenario: Nobody removes the byline + Given the article 10 credits only the author 2 + When the owner sends DELETE /v1/author/2?reassign_to=none + Then the status is 204 + And the article 10 has no authors + And the article 10 still exists, unchanged otherwise + + Scenario: Only the deleted author is removed from a shared article + Given the article 11 credits the authors 5, 2, 6 in that order + When the owner sends DELETE /v1/author/2?reassign_to=none + Then the article 11 credits 5, 6 in that order + + Scenario: Reassign and delete are all-or-nothing + Given the repository fails while deleting the author 2 + When the owner sends DELETE /v1/author/2?reassign_to=3 + Then the credits of the author 2 are unchanged + And the author 2 still exists + + Scenario: The credits are moved by one repository operation + When the owner deletes an author with articles and a valid reassign_to + Then the use case calls a single repository operation that moves the credits and deletes the author + + Scenario Outline: An unusable destination + Given the author 2 is credited on 3 articles + When the owner sends DELETE /v1/author/2?reassign_to= + Then the status is + And the body is "" + And nothing changed + + Examples: + | reassign_to | status | body | + | 999 | 404 | author_not_found | + | 2 | 400 | invalid_reassign | + | abc | 400 | invalid_reassign | + | -1 | 400 | invalid_reassign | + | (empty) | 400 | invalid_reassign | + + Scenario: A linked author can be deleted and the account is untouched + Given the author 2 is linked to the user 3 and credits no article + When the owner deletes the author 2 + Then the user 3 still exists and can create an author again + + Scenario: A missing author is not found + When the owner sends DELETE /v1/author/999 + Then the status is 404 + And the body is "author_not_found" + + Scenario: An unused reassign_to is ignored + Given the author 2 credits no article + When the owner sends DELETE /v1/author/2?reassign_to=3 + Then the status is 204 + And the author 3 is unchanged + + Scenario Outline: Other roles and keys cannot delete + When DELETE /v1/author/2 is sent with + Then the status is 403 + And the body is "" + And no author changed + + Examples: + | credentials | body | + | a verified account with another role | Only owners and admins can do this. | + | a valid API key | This action needs a real session. | + + Scenario: A role without author rights cannot delete its own author + Given the account is linked to the author 4 + When it sends DELETE /v1/author/4 + Then the status is 403 with body "Only owners and admins can do this." + + Scenario: An admin can delete + When a verified admin sends DELETE /v1/author/2 on an author without articles + Then the status is 204 + + Rule: Only owner and admin write other people's authors + + Scenario Outline: Refused writes change nothing + When is sent with + Then the status is 403 + And the body is "" + And no author changed + + Examples: + | request | credentials | body | + | POST /v1/author/ | a verified account with another role | Only owners and admins can do this. | + | POST /v1/author/ | a valid API key | This action needs a real session. | + | PATCH /v1/author/2 | a verified account with another role | author_rights | + + Rule: Articles credit authors in order + + Scenario: An article is credited with ordered authors + Given the authors 5, 6, 7 exist and the article 8 has none + When the user sends PATCH /v1/article/8 with {"author_ids": [6, 5, 7]} + Then the status is 200 + And the article body's authors are 6, 5, 7 in that order + And the first is the primary byline + + Scenario: The author list is replaced and can be emptied + Given the article 8 credits 5, 6 + When the user sends PATCH /v1/article/8 with {"author_ids": [6]} + Then the article 8 credits only 6 + When the user sends PATCH /v1/article/8 with {"author_ids": []} + Then the article 8 has authors [] + + Scenario: Reordering changes the positions + Given the article 8 credits 5, 6, 7 + When the user sends PATCH /v1/article/8 with {"author_ids": [7, 5, 6]} + Then GET /v1/article/8 lists the authors 7, 5, 6 + + Scenario Outline: Any account that can edit the article sets its authors + When PATCH /v1/article/8 is sent with {"author_ids": [5]} and + Then the status is 200 + + Examples: + | credentials | + | the owner's jwt cookie | + | a verified admin's jwt cookie | + | a verified account with another role | + | a valid API key | + + Scenario: The authors and the other fields change together + When the user sends PATCH /v1/article/8 with {"body": "New", "site_id": 2, "author_ids": [5]} + Then the body, the site_id and the authors are all updated + + Scenario: An update without author_ids keeps the authors + Given the article 8 credits 5, 6 + When the user sends PATCH /v1/article/8 with {"body": "New"} + Then the article 8 still credits 5, 6 in that order + + Scenario Outline: An unusable author_ids is refused + When the user sends PATCH /v1/article/8 with {"author_ids": } + Then the status is + And the body is "" + And the article is unchanged + + Examples: + | value | status | body | + | [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 | the validator error text | + + Scenario: A refused update changes no authors + Given the article 8 credits 5 + When the user sends PATCH /v1/article/8 with {"body": "New", "author_ids": [999]} + Then the status is 404 + And the article 8 body is unchanged and it still credits 5 + + Scenario: An unchanged list records nothing + Given the article 8 credits 5, 6 + When the user sends PATCH /v1/article/8 with {"author_ids": [5, 6]} + Then the status is 200 + And no activity event is recorded + + Scenario: An article without authors can be published + Given the article 8 has no authors and is offline + When the user sends PATCH /v1/article/8/publish + Then the status is 200 + And the article is online + + Scenario Outline: Article reads carry the ordered authors + Given the article 8 credits the authors 6 then 5 + When is sent + Then the article in the response has "authors" listing 6 then 5 with only id, name, slug, bio, avatar_id, website and social_links + + Examples: + | request | + | GET /v1/article/8 | + | GET /v1/article/ (each item of items) | + | POST /v1/article | + | PATCH /v1/article/8 | + | PATCH /v1/article/8/publish | + | PATCH /v1/article/8/unpublish | + + Scenario: An article without authors returns an empty list + When GET /v1/article/8 is sent on an article with no authors + Then "authors" is [] + + Scenario: Anonymous readers see the authors of online articles + Given the article 9 is online and credits the linked author 5 + When a caller without credentials sends GET /v1/article/9 + Then the authors are returned + And the body has no user_id, no user_email and no article_count in any author + + Scenario: An offline article is still not found publicly + When a caller without a verified account sends GET /v1/article/{offline id} + Then the status is 404 with body "Article not found" + + Scenario: A new article defaults to the creator's author + Given the caller is linked to the author 5 + When it sends POST /v1/article with {"title": "Spring release", "body": "Hello"} + Then the article is offline + And its authors are [5] + + Scenario: A creator without an author creates an article without authors + Given the caller has no author + When it sends POST /v1/article with {"title": "Spring release", "body": "Hello"} + Then its authors are [] + + Scenario: Deleting an article removes its credits and keeps the author + Given the article 8 credits the author 5, who credits 2 articles + When the article 8 is deleted + Then the author 5 still exists with article_count 1 + + Scenario: article_count counts offline articles too + Given the author 5 credits one online and one offline article + Then the author 5 has article_count 2 + + Scenario: The list filter and counts are unaffected + When GET /v1/article/?status=online&q=spring is sent + Then the items and counts are as before this slice, with authors on each item + + Rule: The current user carries the linked author + + Scenario: GET /v1/auth/me includes the linked author + Given the caller is linked to the author 5 + When GET /v1/auth/me is sent + Then the body has the existing user fields and "author" holding the author 5 (id, name, slug, bio, avatar_id, website, social_links, article_count) with no user_email + + Scenario: A user without an author has author null + When GET /v1/auth/me is sent by an account without an author + Then "author" is null + + Scenario: The field follows link changes + Given the caller was unlinked by an admin + When GET /v1/auth/me is sent + Then "author" is null + + Scenario: The existing fields are unchanged + When GET /v1/auth/me is sent + Then the body still has id, username, email, is_verified, created_at, updated_at, display_name and role + And no password, token or code is present + + Scenario: An API key reads the same body + When GET /v1/auth/me is sent with a valid API key + Then "author" is present as for a session + + Rule: Media usage by avatars + + Scenario: An image used as an avatar is counted as used + Given the image 5 is the avatar of the author 2 and appears in no article or site + When a verified account looks up the usage of the image 5 + Then used_in counts the author and the image is not "Unused" + + Scenario: The media list counts the author + Given the image 5 is used by an article, a site and an author + When a verified account sends GET /v1/media/ + Then the used_in of the image 5 is 3 + + Scenario: Deleting the image clears the author's avatar + When the owner deletes the image 5 + Then the status is 204 + And the author 2 has avatar_id null + + Scenario: Merging copies repoints the author + Given the image 6 is a copy of the image 5 and the author 2 uses the image 6 + When the copies are merged into the image 5 + Then the author 2 has avatar_id 5 + And the merge response reports authors_updated 1 + + Scenario: Purge repoints authors the same way + Given a purge group whose kept image is 5 and whose duplicate 6 is used by the author 2 + When the purge runs + Then the author 2 has avatar_id 5 + And the purge result reports authors_updated 1 + + Scenario: A failed purge group leaves the author pointing at an existing image + Given the purge of a group fails midway + Then every author avatar_id refers to an image that still exists + + Scenario: The usage lookup array shape is unchanged + When a verified account looks up the usage of an image + Then the response keeps the same array shape as before this slice + + Rule: Every author write is recorded in the activity log + + Scenario Outline: A successful write is recorded once after the change + When performs + Then one event exists with action "", category "authors", target {"type": "author", "id": "", "name": ""}, outcome "success", actor and source "web" + And the event was recorded after the change was applied + + Examples: + | actor | request | action | id | name | + | the owner | POST /v1/author/ for "Camille Roche" | author.create | 2 | Camille Roche | + | the caller | POST /v1/author/me for "Marta Alves" | author.create | 3 | Marta Alves | + | the owner | PATCH /v1/author/2 renaming it | author.edit | 2 | Camille R. | + | the owner | PATCH /v1/author/2 linking the user "marta" | author.edit | 2 | Camille R. | + | the owner | DELETE /v1/author/2 | author.delete | 2 | Camille R. | + + Scenario: The change fields are limited and carry no email + When the owner edits an author's name, slug, bio, website, social links, avatar and linked user + Then the changes use only the fields name, slug, bio, website, social_links, avatar and user + And the user change holds usernames, never an email + + Scenario: An own-author edit by a role without author rights is recorded + When the account with another role edits its own author + Then one event "author.edit" exists with that account as actor + + Scenario Outline: A refused write is recorded as failed with a plain reason + When the actor performs which is refused with + Then one event exists with action "", outcome "failed" and failure_reason "" + + Examples: + | request | code | action | reason | + | POST /v1/author/ with a taken slug | author_slug_taken | author.create | That slug is already used by another author. | + | POST /v1/author/me by a linked caller | user_already_linked | author.create | That user already has an author. | + | PATCH /v1/author/2 with a taken slug | author_slug_taken | author.edit | That slug is already used by another author. | + | PATCH /v1/author/2 linking a linked user | user_already_linked | author.edit | That user already has an author. | + | PATCH /v1/author/2 by a non-admin on it | author_rights | author.edit | Only an owner or admin can change this author. | + | DELETE /v1/author/2 with articles | author_has_articles | author.delete | The author is still credited on articles. | + | DELETE /v1/author/2 reassigned to itself | invalid_reassign | author.delete | The destination author is not valid. | + | POST /v1/author/ when the repository fails with "db is locked" | 500 | author.create | Something went wrong on our side. Please try again. | + | DELETE /v1/author/2 when the repository fails | 500 | author.delete | Something went wrong on our side. Please try again. | + + Scenario Outline: Nothing is recorded when there is nothing to attribute + When is sent + Then no activity event is recorded + + Examples: + | request | + | PATCH /v1/author/999 as the owner | + | DELETE /v1/author/999 as the owner | + | POST /v1/author/ with an unknown avatar (image_not_found) | + | POST /v1/author/ with a bio over 280 characters | + | POST /v1/author/ as a role without author rights (403) | + | POST /v1/author/ with no credentials (401) | + | PATCH /v1/author/abc as the owner | + | POST /v1/author/ with a malformed body | + + Scenario: A failing audit repository never changes the response + Given the audit repository fails on Append + When the owner sends POST /v1/author/ with valid values + Then the response equals that of a healthy repository + And the author was created + + Scenario: Reads are never recorded + When GET /v1/author/ is sent + Then no activity event is recorded + + Scenario: An article's authors change is recorded in article.edit + Given the article 8 "Spring release" credits "Inès Duarte" and "Marta Alves" + When the user sends PATCH /v1/article/8 with {"author_ids": [Marta, Inès, Zoe]} + Then one event "article.edit" exists with changes [{"field": "authors", "from": "Inès Duarte, Marta Alves", "to": "Marta Alves, Inès Duarte, Zoe Lambert"}] + + Scenario: Deleting an author with reassignment records only the author event + When the owner deletes the author 2 with reassign_to=3 on 3 articles + Then exactly one event "author.delete" exists + And no "article.edit" event exists + + Scenario: The activity API lists author events + Given author events were recorded + When a verified account sends GET /v1/activity/?category=authors + Then the author events are returned with category "authors" and no other category + + Rule: Persistence and migration + + Scenario: The new tables are migrated + When database.Migrate runs on an empty database + Then the authors and article_authors tables exist + And the authors table has unique indexes on slug and on user_id, where many NULL user_id values are allowed + And article_authors has a unique pair (article_id, author_id) and a position + + Scenario: Existing articles get no authors + Given a database from before this slice holding 4 articles + When database.Migrate runs + Then all 4 articles have no authors and no author is created + + Scenario: It works on every database driver + When the repository contract suite runs + Then the author repository behaves the same on SQLite, MySQL and PostgreSQL + And the SQLite schema test accounts for the new tables + + Scenario: Order is stable + Given the article 8 credits 7, 5, 6 + When it is read back from the repository + Then the authors come back as 7, 5, 6 + + Rule: Documentation + + Scenario: The docs describe the new surface + Then docs/api.md documents the /v1/author routes with auth and roles, the error tokens, the author shape, article author_ids and authors, the author field of /v1/auth/me and the authors_updated media field + And docs/architecture.md mentions the author aggregate, its repository and its use cases diff --git a/features/authors_backend.qa.md b/features/authors_backend.qa.md new file mode 100644 index 0000000..00e03e5 --- /dev/null +++ b/features/authors_backend.qa.md @@ -0,0 +1,111 @@ +# QA procedure: Authors backend + +Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Every error assertion compares the plain-text body to the exact code token or constant named in the spec. + +## Setup + +- New files `test/author_*_test.go`. Fakes have unique names: `mockAuthorRepo` (authors, slug and user_id uniqueness, article counts, ordered article credits, the reassign-and-delete as one recorded operation, failure and unique-violation injection), `mockAuthorUserRepo` (users with ids, usernames, display names, emails and roles), `mockAuthorImageRepo` (images, avatar usage, merge and purge repointing), and an extended `mockArticleRepo` (ordered author credits, create with default author, delete removes credits). A fake audit repository with fail and panic switches is reused. +- Routers mirror `api/router.go`: a new `NewAuthorRouter`, plus the article, auth (`/me`), media and activity routers. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Requests carry signed `jwt` cookies with the `user_id` claim for: an owner, an admin, a verified account whose role is `"editor"` (a value other than owner or admin, because no third role exists yet), and an unverified account. An API-key request uses the key authentication path. +- Helper builders create authors, link users and credit articles directly in the fakes; assertions read the fakes, never the JSON alone. + +## A. Reading + +1. Newest-first list with `total`, `page`, `size` and `article_count`. +2. Credentials table: owner, admin, "editor" and API key give 200; unverified gives 403; none gives 401. +3. `q` table: name, slug, case-insensitive, no match gives total 0. +4. Pagination table: page 2 of 30, size 5, out-of-range page gives empty items, `size=30`, `page=0`, `page=abc` give 400 `apperr.BodyErrorMessage`. +5. Email visibility: owner and admin see `user_email`; "editor" gets a body without any `user_email` key or the email string (substring check on the raw body). +6. Guest author: `is_linked` false, `user_id` null. Null and empty defaults for avatar, website, bio, links. + +## B. Creating + +7. Owner and admin create: 201, full body, stored, `article_count` 0, `is_linked` false. +8. Only the name given: generated slug, defaults. +9. Slug generation table (accents folded, punctuation runs, `!!!` gives `author`). +10. Generated and explicit taken slug: 409 `author_slug_taken`, nothing stored. Repository unique-violation injection gives the same 409. +11. Validation table (name, slug, bio, website, links, link type, avatar) with exact status and token; 422 rows assert the token; boundary table (100-character name and slug, 280 characters and 280 multi-byte characters, 5 links, two custom links, empty website). +12. Malformed JSON gives 400 `apperr.BodyErrorMessage`; "editor" with malformed JSON still gets 403 "Only owners and admins can do this.". +13. `user_id` on create: success, 404 `user_not_found`, 409 `user_already_linked`. + +## C. Own author (`POST /me`) + +14. Naming table: display name, then username fallback; `user_id` is the caller's; `is_linked` true. +15. Owner, admin and "editor" all get 201. +16. Slug de-duplication: `marta-alves-2` taken, result `-3`; the 100-character slug case stays within 100 and ends with `-2`. +17. Already linked: 409 `user_already_linked`, nothing added; unique-violation injection on `user_id` gives the same 409. +18. API key gives 403 "This action needs a real session."; unverified gives the existing 403; none gives 401. + +## D. Editing + +19. Full edit by owner; partial edit keeps absent fields; nullable clear table (avatar, website null and "", links [], bio ""). +20. Avatar set; social links replaced as a whole. +21. Validation table like creation; own slug re-sent is 200; another author's slug is 409; unchanged author after each refusal. +22. `{}`, unknown-only and `{"name": null}` give 400 `apperr.BodyErrorMessage`; missing author 404 `author_not_found`; `abc` id gives `apperr.IdUint32ErrorMessage`. +23. No-op edit: 200 and no audit event. +24. "editor" table: own author 200 (also name, slug, avatar, links); another author 403 `author_rights`; missing id 404; `user_id` in the body of its own author 403 `author_rights` and still linked; API key gives 403 "This action needs a real session." + +## E. Linking and unlinking + +25. Link a guest author: 200, `user_email` visible to the owner. +26. Table: linked user gives 409 `user_already_linked`; already-linked author gives 409 `author_already_linked`; same user is a 200 with no event; unknown user gives 404 `user_not_found`. +27. Unlink: 200, `is_linked` false, no `user_email`; the user can create an author again; the author keeps its article credits; unlinking a guest is a no-op with no event. +28. After unlinking, an article created by that user has no authors. + +## F. Deleting + +29. No articles: 204. With articles and no `reassign_to`: 409 `author_has_articles`, unchanged. +30. Reassign: credits move, author gone; position is preserved (5, 2, 6 becomes 5, 3, 6); no duplicate when the target already credits the article (2, 3 becomes 3). +31. `none`: only that author is removed from shared articles, order of the rest is kept; articles with only that author end with none and still exist. +32. Single repository operation (the fake records one call). Failure injected in it leaves credits and author unchanged. +33. `reassign_to` table: 999 gives 404 `author_not_found`; own id, `abc`, `-1`, empty give 400 `invalid_reassign`. Unused valid `reassign_to` on an author without articles gives 204 and leaves the destination untouched. Missing author gives 404. +34. Linked author deleted: user still exists and can create again. +35. Refusals: "editor" gives 403 "Only owners and admins can do this." including on its own author; API key gives 403 "This action needs a real session."; admin gives 204. + +## G. Articles + +36. `author_ids` PATCH sets, replaces, empties and reorders; reads (GET one, GET list, POST, PATCH, publish, unpublish) carry `authors` in order with only the seven public fields; empty is `[]`. +37. Callers table: owner, admin, "editor", API key all give 200. +38. Combined PATCH with `body` and `site_id`; omission keeps the authors; a refused `author_ids` (404) leaves body and credits unchanged. +39. Unusable table: `[999]`, `[5,999]` 404 `author_not_found`; `[5,5]`, `null`, `"a"`, `[-1]` give 400 `apperr.BodyErrorMessage`; `[0]` gives the validator error text. The article is unchanged in every case. +40. Unchanged list is 200 with no event. Publishing an article with no authors succeeds. +41. Anonymous read of an online article includes `authors` but no `user_id`, `user_email` or `article_count` (substring check); an offline article is 404 "Article not found". +42. Creation default: creator with a linked author gets `[author]`; creator without one gets `[]`; the article is offline. +43. Deleting an article removes its credits and keeps the author, `article_count` drops; `article_count` counts offline articles. +44. List filters (`status`, `q`, `site`) and `counts` are unchanged apart from `authors` on each item. + +## H. Current user + +45. `/v1/auth/me` returns `author` for a linked caller (seven public fields plus `article_count`, no `user_email`) and null otherwise, null again after an unlink; existing fields are unchanged and no secret appears; API key request returns the same shape. + +## I. Media + +46. Usage lookup counts an avatar, shape unchanged, image not "Unused". Media list `used_in` sums article, site and author usage. +47. Deleting the image (204) nulls `avatar_id`. +48. Merge repoints authors and reports `authors_updated`; purge does the same; a failed purge group leaves every `avatar_id` pointing at an existing image. + +## J. Activity log + +49. Success table: one event per write with action, category "authors", target type "author", actor, source "web", recorded after the change (including `/me`). +50. Change fields are limited to name, slug, bio, website, social_links, avatar, user; the user change shows usernames and no email (substring check on the stored event). +51. Failed table with the plain reasons; unknown ids, 403, 401, 422 and other validation refusals and malformed requests record nothing; reads record nothing. +52. Failing and panicking audit repository leave the response and the change as with a healthy one. +53. `article.edit` with an authors change holds `{"field":"authors","from":"Inès Duarte, Marta Alves","to":"Marta Alves, Inès Duarte, Zoe Lambert"}`; delete with reassignment records one `author.delete` and no `article.edit`. +54. `GET /v1/activity/?category=authors` returns only author events for a verified session (an API key gets the route's existing session refusal). Failure reasons use the style of `auditFailures.go`: the author sentinels map to the plain sentences of the feature and any other error gives `GenericFailureReason`. + +## K. Persistence and migration + +55. Repository-contract harness (temp-dir SQLite; MySQL and Postgres opt-in through the existing env switches): author repository contract (CRUD, slug and user uniqueness with many NULL users, search, newest-first pagination, ordered credits and stable read-back, unique (article, author) pair, transactional reassign-and-delete incl. the no-duplicate and position rules). `database.Migrate` creates both tables and leaves existing articles without authors; `test/sqlite_schema_unchanged_test.go` is updated for the new tables. + +## L. Quality gates + +56. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; the domain author package imports no GORM or HTTP; handlers import no persistence; validation uses validate tags and `validator.Validate.Struct`; no comments added. +57. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES= MUTATE_RUN='TestAuthor' MUTATE_WORKERS=2 task mutate -- `; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`. +58. Docs, checked by reading the files: docs/api.md documents the five /v1/author routes (GET, POST, POST /me, PATCH, DELETE) with auth and roles, error tokens, the author and article-author shapes, `author_ids`, the `/auth/me` `author` field and `authors_updated`; docs/architecture.md names the author aggregate, repository and use cases. + +## Manual + +- MySQL and Postgres: run the contract suite with the opt-in env vars against real servers. +- Real boot with an old database copy: both new tables appear, articles keep working with no authors, second boot changes nothing. +- Docker with the `/data` volume: same boot check. +- Read the activity log screen and the CSV export: author events show names, failure reasons and no emails. +- Not covered here: the SPA (later slices). diff --git a/go.mod b/go.mod index 8a74d9a..9f5123f 100644 --- a/go.mod +++ b/go.mod @@ -12,13 +12,13 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 - github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-playground/validator/v10 v10.28.0 github.com/go-sql-driver/mysql v1.8.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 - github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/lestrrat-go/jwx/v3 v3.3.0 golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 golang.org/x/net v0.59.0 @@ -47,7 +47,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect github.com/aws/smithy-go v1.28.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -55,7 +55,7 @@ require ( github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -63,15 +63,17 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.10 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/sys v0.48.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index e5325d7..3dfeff4 100644 --- a/go.sum +++ b/go.sum @@ -41,10 +41,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWx github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= @@ -63,8 +61,8 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= -github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= +github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -75,8 +73,8 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0 github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -103,16 +101,18 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= +github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -121,7 +121,6 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -129,11 +128,14 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= @@ -159,8 +161,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= diff --git a/internal/domain/article/articleDomain.go b/internal/domain/article/articleDomain.go index d7466f8..0486f52 100644 --- a/internal/domain/article/articleDomain.go +++ b/internal/domain/article/articleDomain.go @@ -1,6 +1,7 @@ package article import ( + "RenewCMS/internal/domain/author" "errors" "time" "unicode/utf8" @@ -26,11 +27,12 @@ func ValidateTitle(title string) error { } type Article struct { - ID uint32 `json:"id"` - Title string `json:"title"` - Body string `json:"body"` - IsOnline bool `json:"is_online"` - SiteID uint32 `json:"site_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint32 `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + IsOnline bool `json:"is_online"` + SiteID uint32 `json:"site_id"` + Authors []author.Byline `json:"authors"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/domain/article/articleRepositoryInterface.go b/internal/domain/article/articleRepositoryInterface.go index cf063f1..59e2b6f 100644 --- a/internal/domain/article/articleRepositoryInterface.go +++ b/internal/domain/article/articleRepositoryInterface.go @@ -39,8 +39,9 @@ type Filters struct { } type Update struct { - Body *string - SiteID *uint32 + Body *string + SiteID *uint32 + AuthorIDs *[]uint32 } type Counts struct { diff --git a/internal/domain/audit/auditDomain.go b/internal/domain/audit/auditDomain.go index e25dc01..f0dfbf4 100644 --- a/internal/domain/audit/auditDomain.go +++ b/internal/domain/audit/auditDomain.go @@ -33,6 +33,7 @@ const ( CategoryAuth Category = "auth" CategorySystem Category = "system" CategorySites Category = "sites" + CategoryAuthors Category = "authors" ) type Outcome string @@ -76,6 +77,9 @@ const ( ActionSiteEdit = "site.edit" ActionSiteSetDefault = "site.set_default" ActionSiteDelete = "site.delete" + ActionAuthorCreate = "author.create" + ActionAuthorEdit = "author.edit" + ActionAuthorDelete = "author.delete" ActionMediaUpload = "media.upload" ActionMediaRename = "media.rename" ActionMediaDelete = "media.delete" @@ -113,6 +117,7 @@ const ( TargetInvite = "invite" TargetRelease = "release" TargetSite = "site" + TargetAuthor = "author" ) const ( @@ -158,6 +163,13 @@ const ( FieldLocale = "locale" FieldSocialImage = "default_social_image" FieldSite = "site" + FieldSlug = "slug" + FieldBio = "bio" + FieldWebsite = "website" + FieldSocialLinks = "social_links" + FieldAvatar = "avatar" + FieldUser = "user" + FieldAuthors = "authors" maxNameLength = 255 maxActionLength = 64 maxFailureReasonSize = 255 @@ -168,7 +180,7 @@ func ActorTypes() []ActorType { } func Categories() []Category { - return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites} + return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors} } func Outcomes() []Outcome { diff --git a/internal/domain/author/authorDomain.go b/internal/domain/author/authorDomain.go new file mode 100644 index 0000000..885ad93 --- /dev/null +++ b/internal/domain/author/authorDomain.go @@ -0,0 +1,216 @@ +package author + +import ( + "errors" + "net/url" + "regexp" + "strconv" + "strings" + "unicode/utf8" +) + +var ( + ErrAuthorNotFound = errors.New("author not found") + ErrSlugTaken = errors.New("an author with that slug already exists") + ErrAuthorHasArticles = errors.New("the author is still credited on articles") + ErrUserAlreadyLinked = errors.New("the user already has an author") + ErrAuthorAlreadyLinked = errors.New("the author is already linked to a user") + ErrAuthorRights = errors.New("only an owner or admin can change this author") + ErrBioTooLong = errors.New("the bio is too long") + ErrInvalidURL = errors.New("invalid url") + ErrTooManyLinks = errors.New("too many social links") + ErrInvalidSlug = errors.New("invalid slug") + ErrInvalidReassign = errors.New("invalid reassign destination") + ErrInvalidSocialType = errors.New("invalid social link type") +) + +const ( + MinNameRunes = 1 + MaxNameRunes = 100 + MaxSlugRunes = 100 + MaxBioRunes = 280 + MaxURLLength = 2048 + MaxLinks = 5 + + FallbackSlug = "author" + + SocialX = "x" + SocialLinkedIn = "linkedin" + SocialGitHub = "github" + SocialMastodon = "mastodon" + SocialCustom = "custom" +) + +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +var nonSlugRun = regexp.MustCompile(`[^a-z0-9]+`) + +var diacriticFolder = strings.NewReplacer( + "à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a", + "æ", "ae", + "ç", "c", "ć", "c", "č", "c", "ĉ", "c", + "ď", "d", "đ", "d", + "è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e", + "ğ", "g", + "ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i", + "ł", "l", "ľ", "l", + "ñ", "n", "ń", "n", "ň", "n", + "ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o", + "œ", "oe", + "ř", "r", + "ś", "s", "š", "s", "ş", "s", "ș", "s", + "ß", "ss", + "ť", "t", "ț", "t", "ţ", "t", + "ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u", + "ý", "y", "ÿ", "y", + "ź", "z", "ż", "z", "ž", "z", +) + +type SocialLink struct { + Type string `json:"type"` + URL string `json:"url"` +} + +type Author struct { + ID uint32 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Bio string `json:"bio"` + AvatarID *uint32 `json:"avatar_id"` + Website *string `json:"website"` + SocialLinks []SocialLink `json:"social_links"` + UserID *uint32 `json:"user_id"` + IsLinked bool `json:"is_linked"` + ArticleCount int `json:"article_count"` + UserEmail *string `json:"user_email,omitempty"` +} + +type Byline struct { + ID uint32 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Bio string `json:"bio"` + AvatarID *uint32 `json:"avatar_id"` + Website *string `json:"website"` + SocialLinks []SocialLink `json:"social_links"` +} + +func (a Author) Byline() Byline { + links := a.SocialLinks + if links == nil { + links = []SocialLink{} + } + return Byline{ID: a.ID, Name: a.Name, Slug: a.Slug, Bio: a.Bio, AvatarID: a.AvatarID, Website: a.Website, SocialLinks: links} +} + +type Nullable[T any] struct { + Set bool + Value *T +} + +type Patch struct { + Name *string + Slug *string + Bio *string + AvatarID Nullable[uint32] + Website Nullable[string] + SocialLinks *[]SocialLink + UserID Nullable[uint32] +} + +type ListQuery struct { + Q string + Page int + Size int +} + +type Page struct { + Items []Author + Total int +} + +func IsValidSlug(slug string) bool { + length := utf8.RuneCountInString(slug) + return length >= 1 && length <= MaxSlugRunes && slugPattern.MatchString(slug) +} + +func IsValidName(name string) bool { + length := utf8.RuneCountInString(strings.TrimSpace(name)) + return length >= MinNameRunes && length <= MaxNameRunes +} + +func ValidateBio(bio string) error { + if utf8.RuneCountInString(bio) > MaxBioRunes { + return ErrBioTooLong + } + return nil +} + +func ValidateURL(raw string) error { + if raw == "" || len(raw) > MaxURLLength { + return ErrInvalidURL + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { + return ErrInvalidURL + } + return nil +} + +func NormalizeWebsite(website *string) (*string, error) { + if website == nil || *website == "" { + return nil, nil + } + if err := ValidateURL(*website); err != nil { + return nil, err + } + return website, nil +} + +func IsValidSocialType(linkType string) bool { + switch linkType { + case SocialX, SocialLinkedIn, SocialGitHub, SocialMastodon, SocialCustom: + return true + } + return false +} + +func ValidateLinks(links []SocialLink) error { + if len(links) > MaxLinks { + return ErrTooManyLinks + } + for _, link := range links { + if !IsValidSocialType(link.Type) { + return ErrInvalidSocialType + } + if err := ValidateURL(link.URL); err != nil { + return err + } + } + return nil +} + +func GenerateSlug(name string) string { + folded := diacriticFolder.Replace(strings.ToLower(name)) + slug := strings.Trim(nonSlugRun.ReplaceAllString(folded, "-"), "-") + slug = truncateSlug(slug, MaxSlugRunes) + if slug == "" { + return FallbackSlug + } + return slug +} + +func truncateSlug(slug string, limit int) string { + if len(slug) <= limit { + return slug + } + return strings.TrimRight(slug[:limit], "-") +} + +func SlugWithSuffix(base string, attempt int) string { + if attempt <= 1 { + return base + } + suffix := "-" + strconv.Itoa(attempt) + return truncateSlug(base, MaxSlugRunes-len(suffix)) + suffix +} diff --git a/internal/domain/author/authorRepositoryInterface.go b/internal/domain/author/authorRepositoryInterface.go new file mode 100644 index 0000000..35d3f75 --- /dev/null +++ b/internal/domain/author/authorRepositoryInterface.go @@ -0,0 +1,16 @@ +package author + +type Repository interface { + List(query ListQuery) (Page, error) + Get(id uint32) (Author, error) + GetBySlug(slug string) (Author, error) + GetByUserID(userID uint32) (Author, error) + Create(author Author) (Author, error) + Update(author Author) (Author, error) + DeleteMovingCredits(id uint32, reassignTo *uint32) error + DeleteUncrediting(id uint32) error + AvatarUsage() (map[uint32]int, error) + ClearAvatar(imageID uint32) ([]uint32, error) + AssignAvatar(authorIDs []uint32, imageID uint32) error + RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) +} diff --git a/internal/domain/purge/purgeDomain.go b/internal/domain/purge/purgeDomain.go index 1237463..3ca87e4 100644 --- a/internal/domain/purge/purgeDomain.go +++ b/internal/domain/purge/purgeDomain.go @@ -53,5 +53,6 @@ type RunResult struct { Merged int ArticlesUpdated int SitesUpdated int + AuthorsUpdated int FailedGroups int } diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index ae6b69e..daaab20 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -7,7 +7,7 @@ import ( ) func Migrate(db *gorm.DB, publicURL string) error { - err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}) + err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}) if err != nil { return err } diff --git a/internal/infrastructure/persistence/mappers/authorMapper.go b/internal/infrastructure/persistence/mappers/authorMapper.go new file mode 100644 index 0000000..a9e5964 --- /dev/null +++ b/internal/infrastructure/persistence/mappers/authorMapper.go @@ -0,0 +1,59 @@ +package mappers + +import ( + "RenewCMS/internal/domain/author" + "RenewCMS/internal/infrastructure/persistence/models" + "encoding/json" +) + +const emptyLinks = "[]" + +func decodeLinks(raw string) []author.SocialLink { + links := []author.SocialLink{} + if raw == "" { + return links + } + if err := json.Unmarshal([]byte(raw), &links); err != nil || links == nil { + return []author.SocialLink{} + } + return links +} + +func encodeLinks(links []author.SocialLink) string { + if len(links) == 0 { + return emptyLinks + } + encoded, err := json.Marshal(links) + if err != nil { + return emptyLinks + } + return string(encoded) +} + +func AuthorToDomain(m models.Author, articleCount int) author.Author { + return author.Author{ + ID: m.ID, + Name: m.Name, + Slug: m.Slug, + Bio: m.Bio, + AvatarID: m.AvatarID, + Website: m.Website, + SocialLinks: decodeLinks(m.SocialLinks), + UserID: m.UserID, + IsLinked: m.UserID != nil, + ArticleCount: articleCount, + } +} + +func AuthorToModel(a author.Author) models.Author { + return models.Author{ + ID: a.ID, + Name: a.Name, + Slug: a.Slug, + Bio: a.Bio, + AvatarID: a.AvatarID, + Website: a.Website, + SocialLinks: encodeLinks(a.SocialLinks), + UserID: a.UserID, + } +} diff --git a/internal/infrastructure/persistence/models/authorModel.go b/internal/infrastructure/persistence/models/authorModel.go new file mode 100644 index 0000000..9c5e83c --- /dev/null +++ b/internal/infrastructure/persistence/models/authorModel.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type Author struct { + ID uint32 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:100;not null"` + Slug string `gorm:"size:100;not null;uniqueIndex"` + Bio string `gorm:"size:280;not null"` + AvatarID *uint32 `gorm:"index"` + Website *string `gorm:"size:2048"` + SocialLinks string `gorm:"type:text;not null"` + UserID *uint32 `gorm:"uniqueIndex"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +type ArticleAuthor struct { + ID uint32 `gorm:"primaryKey;autoIncrement"` + ArticleID uint32 `gorm:"not null;uniqueIndex:idx_article_authors_pair"` + AuthorID uint32 `gorm:"not null;uniqueIndex:idx_article_authors_pair;index"` + Position int `gorm:"not null"` +} diff --git a/internal/infrastructure/persistence/repositories/articleRepository.go b/internal/infrastructure/persistence/repositories/articleRepository.go index e4cf88c..ee532c1 100644 --- a/internal/infrastructure/persistence/repositories/articleRepository.go +++ b/internal/infrastructure/persistence/repositories/articleRepository.go @@ -2,6 +2,7 @@ package persistence import ( domain "RenewCMS/internal/domain/article" + authordomain "RenewCMS/internal/domain/author" "RenewCMS/internal/infrastructure/persistence/mappers" entity "RenewCMS/internal/infrastructure/persistence/models" @@ -89,7 +90,11 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult, if err != nil { return domain.PageResult{}, err } - return domain.PageResult{Items: toDomainArticles(entityArticles), Total: int(total)}, nil + items, err := a.withAuthors(a.db, toDomainArticles(entityArticles)) + if err != nil { + return domain.PageResult{}, err + } + return domain.PageResult{Items: items, Total: int(total)}, nil } func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.PageResult, error) { @@ -125,7 +130,11 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa ordered = append(ordered, entityArticle) } } - page.Items = toDomainArticles(ordered) + items, err := a.withAuthors(a.db, toDomainArticles(ordered)) + if err != nil { + return domain.PageResult{}, err + } + page.Items = items return page, nil } @@ -150,6 +159,77 @@ func toDomainArticles(entityArticles []entity.Article) []domain.Article { return domainArticles } +func (a *ArticleRepository) withAuthors(db *gorm.DB, articles []domain.Article) ([]domain.Article, error) { + ids := make([]uint32, 0, len(articles)) + for _, article := range articles { + ids = append(ids, article.ID) + } + bylines, err := loadBylines(db, ids) + if err != nil { + return nil, err + } + for index := range articles { + articles[index].Authors = bylines[articles[index].ID] + if articles[index].Authors == nil { + articles[index].Authors = []authordomain.Byline{} + } + } + return articles, nil +} + +func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article) (domain.Article, error) { + loaded, err := a.withAuthors(db, []domain.Article{article}) + if err != nil { + return domain.Article{}, err + } + return loaded[0], nil +} + +func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) { + bylines := make(map[uint32][]authordomain.Byline, len(articleIDs)) + if len(articleIDs) == 0 { + return bylines, nil + } + var credits []entity.ArticleAuthor + if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil { + return nil, err + } + if len(credits) == 0 { + return bylines, nil + } + authorIDs := make([]uint32, 0, len(credits)) + for _, credit := range credits { + authorIDs = append(authorIDs, credit.AuthorID) + } + var found []entity.Author + if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil { + return nil, err + } + byID := make(map[uint32]entity.Author, len(found)) + for _, model := range found { + byID[model.ID] = model + } + for _, credit := range credits { + if model, ok := byID[credit.AuthorID]; ok { + bylines[credit.ArticleID] = append(bylines[credit.ArticleID], mappers.AuthorToDomain(model, 0).Byline()) + } + } + return bylines, nil +} + +func replaceCredits(tx *gorm.DB, articleID uint32, authorIDs []uint32) error { + if err := tx.Where("article_id = ?", articleID).Delete(&entity.ArticleAuthor{}).Error; err != nil { + return err + } + for position, authorID := range authorIDs { + credit := entity.ArticleAuthor{ArticleID: articleID, AuthorID: authorID, Position: position} + if err := tx.Create(&credit).Error; err != nil { + return err + } + } + return nil +} + func foldForSearch(value string) string { folder := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) folded, _, err := transform.String(folder, value) @@ -166,7 +246,7 @@ func (a *ArticleRepository) Get(id uint32) (domain.Article, error) { return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound) } - return mappers.ArticleToDomain(article), nil + return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article)) } func (a *ArticleRepository) GetByName(name string) (domain.Article, error) { @@ -176,7 +256,7 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) { return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound) } - return mappers.ArticleToDomain(article), nil + return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article)) } func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) { @@ -192,12 +272,23 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro return err } } - return tx.Create(&created).Error + if err := tx.Create(&created).Error; err != nil { + return err + } + return replaceCredits(tx, created.ID, bylineIDs(article.Authors)) }) if err != nil { return domain.Article{}, err } - return mappers.ArticleToDomain(created), nil + return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created)) +} + +func bylineIDs(bylines []authordomain.Byline) []uint32 { + ids := make([]uint32, 0, len(bylines)) + for _, byline := range bylines { + ids = append(ids, byline.ID) + } + return ids } func (a *ArticleRepository) GetAll() []domain.Article { @@ -228,9 +319,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, return domain.Article{}, err } - newArticle := mappers.ArticleToDomain(localArticle) - - return newArticle, nil + return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle)) } func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) { @@ -250,12 +339,18 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti if update.SiteID != nil { localArticle.SiteID = *update.SiteID } - return tx.Save(&localArticle).Error + if err := tx.Save(&localArticle).Error; err != nil { + return err + } + if update.AuthorIDs != nil { + return replaceCredits(tx, id, *update.AuthorIDs) + } + return nil }) if err != nil { return domain.Article{}, err } - return mappers.ArticleToDomain(localArticle), nil + return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle)) } func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) { @@ -271,12 +366,16 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art return domain.Article{}, err } - return mappers.ArticleToDomain(localArticle), nil + return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle)) } func (a *ArticleRepository) Delete(id uint32) error { - result := a.db.Delete(&entity.Article{}, id) - return deleteOrNotFound(result, domain.ErrArticleNotFound) + return a.db.Transaction(func(tx *gorm.DB) error { + if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil { + return err + } + return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error + }) } func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) { diff --git a/internal/infrastructure/persistence/repositories/authorRepository.go b/internal/infrastructure/persistence/repositories/authorRepository.go new file mode 100644 index 0000000..f5e867d --- /dev/null +++ b/internal/infrastructure/persistence/repositories/authorRepository.go @@ -0,0 +1,357 @@ +package persistence + +import ( + domain "RenewCMS/internal/domain/author" + "RenewCMS/internal/infrastructure/persistence/mappers" + entity "RenewCMS/internal/infrastructure/persistence/models" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AuthorRepository struct { + db *gorm.DB +} + +func NewAuthorRepository(db *gorm.DB) *AuthorRepository { + return &AuthorRepository{db} +} + +type creditTotal struct { + AuthorID uint32 + Total int +} + +func creditCounts(db *gorm.DB, authorIDs []uint32) (map[uint32]int, error) { + counts := make(map[uint32]int, len(authorIDs)) + if len(authorIDs) == 0 { + return counts, nil + } + var totals []creditTotal + err := db.Table("article_authors"). + Select("article_authors.author_id AS author_id, COUNT(*) AS total"). + Joins("JOIN articles ON articles.id = article_authors.article_id"). + Where("articles.deleted_at IS NULL"). + Where("article_authors.author_id IN ?", authorIDs). + Group("article_authors.author_id"). + Scan(&totals).Error + if err != nil { + return nil, err + } + for _, total := range totals { + counts[total.AuthorID] = total.Total + } + return counts, nil +} + +func (a *AuthorRepository) toDomain(db *gorm.DB, models []entity.Author) ([]domain.Author, error) { + ids := make([]uint32, 0, len(models)) + for _, model := range models { + ids = append(ids, model.ID) + } + counts, err := creditCounts(db, ids) + if err != nil { + return nil, err + } + authors := make([]domain.Author, 0, len(models)) + for _, model := range models { + authors = append(authors, mappers.AuthorToDomain(model, counts[model.ID])) + } + return authors, nil +} + +func (a *AuthorRepository) loadOne(db *gorm.DB, model entity.Author) (domain.Author, error) { + authors, err := a.toDomain(db, []entity.Author{model}) + if err != nil { + return domain.Author{}, err + } + return authors[0], nil +} + +func (a *AuthorRepository) List(query domain.ListQuery) (domain.Page, error) { + query.Page = max(query.Page, 1) + query.Size = max(query.Size, 0) + needle := strings.TrimSpace(query.Q) + if needle != "" { + return a.findMatching(query, foldForSearch(needle)) + } + var total int64 + if err := a.db.Model(&entity.Author{}).Count(&total).Error; err != nil { + return domain.Page{}, err + } + var found []entity.Author + err := a.db.Order("id DESC").Limit(query.Size).Offset(pageStart(query.Page, query.Size, int(total))).Find(&found).Error + if err != nil { + return domain.Page{}, err + } + items, err := a.toDomain(a.db, found) + if err != nil { + return domain.Page{}, err + } + return domain.Page{Items: items, Total: int(total)}, nil +} + +func pageStart(page int, size int, total int) int { + if size <= 0 || page-1 > total/size { + return total + } + return min((page-1)*size, total) +} + +func (a *AuthorRepository) findMatching(query domain.ListQuery, needle string) (domain.Page, error) { + var candidates []entity.Author + if err := a.db.Select("id", "name", "slug").Order("id DESC").Find(&candidates).Error; err != nil { + return domain.Page{}, err + } + matching := make([]uint32, 0) + for _, candidate := range candidates { + if strings.Contains(foldForSearch(candidate.Name), needle) || strings.Contains(foldForSearch(candidate.Slug), needle) { + matching = append(matching, candidate.ID) + } + } + start := pageStart(query.Page, query.Size, len(matching)) + end := min(start+query.Size, len(matching)) + page := domain.Page{Items: []domain.Author{}, Total: len(matching)} + if start == end { + return page, nil + } + var found []entity.Author + if err := a.db.Where("id IN ?", matching[start:end]).Order("id DESC").Find(&found).Error; err != nil { + return domain.Page{}, err + } + items, err := a.toDomain(a.db, found) + if err != nil { + return domain.Page{}, err + } + page.Items = items + return page, nil +} + +func (a *AuthorRepository) Get(id uint32) (domain.Author, error) { + var model entity.Author + if err := a.db.First(&model, id).Error; err != nil { + return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound) + } + return a.loadOne(a.db, model) +} + +func (a *AuthorRepository) GetBySlug(slug string) (domain.Author, error) { + var model entity.Author + if err := a.db.Where("slug = ?", slug).First(&model).Error; err != nil { + return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound) + } + return a.loadOne(a.db, model) +} + +func (a *AuthorRepository) GetByUserID(userID uint32) (domain.Author, error) { + var model entity.Author + if err := a.db.Where("user_id = ?", userID).First(&model).Error; err != nil { + return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound) + } + return a.loadOne(a.db, model) +} + +func slugInUse(tx *gorm.DB, slug string, exceptID uint32) (bool, error) { + var total int64 + err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", slug, exceptID).Count(&total).Error + return total > 0, err +} + +func userInUse(tx *gorm.DB, userID *uint32, exceptID uint32) (bool, error) { + if userID == nil { + return false, nil + } + var total int64 + err := tx.Model(&entity.Author{}).Where("user_id = ? AND id <> ?", *userID, exceptID).Count(&total).Error + return total > 0, err +} + +func ensureUnique(tx *gorm.DB, author domain.Author) error { + taken, err := slugInUse(tx, author.Slug, author.ID) + if err != nil { + return err + } + if taken { + return domain.ErrSlugTaken + } + linked, err := userInUse(tx, author.UserID, author.ID) + if err != nil { + return err + } + if linked { + return domain.ErrUserAlreadyLinked + } + return nil +} + +func (a *AuthorRepository) explainWriteFailure(author domain.Author, cause error) error { + if err := ensureUnique(a.db, author); err != nil { + return err + } + return cause +} + +func (a *AuthorRepository) Create(author domain.Author) (domain.Author, error) { + model := mappers.AuthorToModel(author) + model.ID = 0 + author.ID = 0 + err := a.db.Transaction(func(tx *gorm.DB) error { + if err := ensureUnique(tx, author); err != nil { + return err + } + return tx.Create(&model).Error + }) + if err != nil { + return domain.Author{}, a.explainWriteFailure(author, err) + } + return mappers.AuthorToDomain(model, 0), nil +} + +func (a *AuthorRepository) Update(author domain.Author) (domain.Author, error) { + changes := map[string]any{ + "name": author.Name, + "slug": author.Slug, + "bio": author.Bio, + "avatar_id": author.AvatarID, + "website": author.Website, + "social_links": mappers.AuthorToModel(author).SocialLinks, + "user_id": author.UserID, + } + err := a.db.Transaction(func(tx *gorm.DB) error { + if _, err := lockAuthor(tx, author.ID); err != nil { + return err + } + if err := ensureUnique(tx, author); err != nil { + return err + } + return tx.Model(&entity.Author{}).Where("id = ?", author.ID).Updates(changes).Error + }) + if err != nil { + return domain.Author{}, a.explainWriteFailure(author, err) + } + return a.Get(author.ID) +} + +func lockAuthor(tx *gorm.DB, id uint32) (entity.Author, error) { + var locked entity.Author + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil { + return entity.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound) + } + return locked, nil +} + +func (a *AuthorRepository) DeleteMovingCredits(id uint32, reassignTo *uint32) error { + return a.db.Transaction(func(tx *gorm.DB) error { + if _, err := lockAuthor(tx, id); err != nil { + return err + } + if reassignTo == nil { + credited, err := hasCredits(tx, id) + if err != nil { + return err + } + if credited { + return domain.ErrAuthorHasArticles + } + return removeAuthor(tx, id) + } + if _, err := lockAuthor(tx, *reassignTo); err != nil { + return err + } + if err := moveCredits(tx, id, *reassignTo); err != nil { + return err + } + return removeAuthor(tx, id) + }) +} + +func (a *AuthorRepository) DeleteUncrediting(id uint32) error { + return a.db.Transaction(func(tx *gorm.DB) error { + if _, err := lockAuthor(tx, id); err != nil { + return err + } + return removeAuthor(tx, id) + }) +} + +func hasCredits(tx *gorm.DB, id uint32) (bool, error) { + var total int64 + err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", id).Count(&total).Error + return total > 0, err +} + +func removeAuthor(tx *gorm.DB, id uint32) error { + if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil { + return err + } + return deleteOrNotFound(tx.Delete(&entity.Author{}, id), domain.ErrAuthorNotFound) +} + +func moveCredits(tx *gorm.DB, from uint32, to uint32) error { + var alreadyCredited []uint32 + if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", to).Pluck("article_id", &alreadyCredited).Error; err != nil { + return err + } + if len(alreadyCredited) > 0 { + if err := tx.Where("author_id = ? AND article_id IN ?", from, alreadyCredited).Delete(&entity.ArticleAuthor{}).Error; err != nil { + return err + } + } + return tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", from).UpdateColumn("author_id", to).Error +} + +type avatarTotal struct { + AvatarID uint32 + Total int +} + +func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) { + var totals []avatarTotal + err := a.db.Model(&entity.Author{}).Select("avatar_id, COUNT(*) AS total").Where("avatar_id IS NOT NULL").Group("avatar_id").Scan(&totals).Error + if err != nil { + return nil, err + } + usage := make(map[uint32]int, len(totals)) + for _, total := range totals { + usage[total.AvatarID] = total.Total + } + return usage, nil +} + +func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) { + ids := []uint32{} + err := a.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&entity.Author{}).Where("avatar_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil { + return err + } + if len(ids) == 0 { + return nil + } + return tx.Model(&entity.Author{}).Where("id IN ?", ids).UpdateColumn("avatar_id", nil).Error + }) + if err != nil { + return nil, err + } + return ids, nil +} + +func (a *AuthorRepository) AssignAvatar(authorIDs []uint32, imageID uint32) error { + if len(authorIDs) == 0 { + return nil + } + return a.db.Model(&entity.Author{}).Where("id IN ?", authorIDs).UpdateColumn("avatar_id", imageID).Error +} + +func (a *AuthorRepository) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) { + if len(fromImageIDs) == 0 { + return 0, nil + } + result := a.db.Model(&entity.Author{}).Where("avatar_id IN ?", fromImageIDs).UpdateColumn("avatar_id", toImageID) + if result.Error != nil { + return 0, result.Error + } + return int(result.RowsAffected), nil +} + +var _ domain.Repository = &AuthorRepository{} diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index b1665a3..4a78c86 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -3,8 +3,10 @@ package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/site" "context" + "errors" ) type CreateArticleUseCase struct { @@ -12,16 +14,18 @@ type CreateArticleUseCase struct { bodyRenderer article.BodyRenderer recorder audit.Recorder sites site.Repository + authors author.Repository } type CreateArticleCommand struct { - Title string - Body string - Format article.BodyFormat + Title string + Body string + Format article.BodyFormat + CreatedBy uint32 } -func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *CreateArticleUseCase { - return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites} +func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase { + return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors} } func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) { @@ -32,6 +36,17 @@ func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) { return defaultSite.ID, nil } +func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, error) { + found, err := g.authors.GetByUserID(userID) + if errors.Is(err, author.ErrAuthorNotFound) { + return []author.Byline{}, nil + } + if err != nil { + return nil, err + } + return []author.Byline{found.Byline()}, nil +} + func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) { event := audit.NewEvent(audit.ActionArticleCreate, audit.CategoryArticles) if err := article.ValidateTitle(createArticle.Title); err != nil { @@ -45,11 +60,18 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle return article.Article{}, err } + creditedAuthors, err := g.creatorBylines(createArticle.CreatedBy) + if err != nil { + recordFailure(ctx, g.recorder, event, err) + return article.Article{}, err + } + created, err := g.articleRepository.Create(article.Article{ Title: createArticle.Title, Body: createArticle.Body, IsOnline: false, SiteID: siteID, + Authors: creditedAuthors, }) if err != nil { recordFailure(ctx, g.recorder, event, err) diff --git a/internal/infrastructure/useCases/CreateAuthor.go b/internal/infrastructure/useCases/CreateAuthor.go new file mode 100644 index 0000000..385d363 --- /dev/null +++ b/internal/infrastructure/useCases/CreateAuthor.go @@ -0,0 +1,106 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/user" + "context" + "strings" +) + +type CreateAuthorUseCase struct { + authors author.Repository + users user.Repository + images image.Repository + recorder audit.Recorder +} + +type CreateAuthorCommand struct { + Name string + Slug string + Bio string + AvatarID *uint32 + Website *string + SocialLinks []author.SocialLink + UserID *uint32 +} + +func NewCreateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *CreateAuthorUseCase { + return &CreateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder} +} + +func (u *CreateAuthorUseCase) Create(ctx context.Context, command CreateAuthorCommand) (author.Author, error) { + command.Name = strings.TrimSpace(command.Name) + event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors) + event.Target = audit.Target{Type: audit.TargetAuthor, Name: command.Name} + created, err := u.create(command) + if err != nil { + recordFailure(ctx, u.recorder, event, err) + return author.Author{}, err + } + event.Target = authorTarget(created) + u.recorder.Record(ctx, event) + return withUserEmail(u.users, created), nil +} + +func (u *CreateAuthorUseCase) create(command CreateAuthorCommand) (author.Author, error) { + website, err := validateAuthorContent(command.Bio, command.Website, command.SocialLinks) + if err != nil { + return author.Author{}, err + } + if err := ensureImageExists(u.images, command.AvatarID); err != nil { + return author.Author{}, err + } + if command.UserID != nil { + if err := ensureUserCanBeLinked(u.users, u.authors, *command.UserID); err != nil { + return author.Author{}, err + } + } + slug, err := u.slugFor(command) + if err != nil { + return author.Author{}, err + } + links := command.SocialLinks + if links == nil { + links = []author.SocialLink{} + } + return u.authors.Create(author.Author{ + Name: command.Name, + Slug: slug, + Bio: command.Bio, + AvatarID: command.AvatarID, + Website: website, + SocialLinks: links, + UserID: command.UserID, + IsLinked: command.UserID != nil, + }) +} + +func (u *CreateAuthorUseCase) slugFor(command CreateAuthorCommand) (string, error) { + slug := command.Slug + if slug == "" { + slug = author.GenerateSlug(command.Name) + } + if !author.IsValidSlug(slug) { + return "", author.ErrInvalidSlug + } + taken, err := isSlugTaken(u.authors, slug) + if err != nil { + return "", err + } + if taken { + return "", author.ErrSlugTaken + } + return slug, nil +} + +func validateAuthorContent(bio string, website *string, links []author.SocialLink) (*string, error) { + if err := author.ValidateBio(bio); err != nil { + return nil, err + } + if err := author.ValidateLinks(links); err != nil { + return nil, err + } + return author.NormalizeWebsite(website) +} diff --git a/internal/infrastructure/useCases/CreateMyAuthor.go b/internal/infrastructure/useCases/CreateMyAuthor.go new file mode 100644 index 0000000..2c1881a --- /dev/null +++ b/internal/infrastructure/useCases/CreateMyAuthor.go @@ -0,0 +1,80 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/user" + "context" + "errors" + "strings" +) + +const maxOwnSlugAttempts = 50 + +type CreateMyAuthorUseCase struct { + authors author.Repository + users user.Repository + recorder audit.Recorder +} + +func NewCreateMyAuthorUseCase(authors author.Repository, users user.Repository, recorder audit.Recorder) *CreateMyAuthorUseCase { + return &CreateMyAuthorUseCase{authors: authors, users: users, recorder: recorder} +} + +func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (author.Author, error) { + caller, err := u.users.Get(userID) + if err != nil { + return author.Author{}, err + } + name := authorNameOf(caller) + event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors) + event.Target = audit.Target{Type: audit.TargetAuthor, Name: name} + created, err := u.create(caller, name) + if err != nil { + recordFailure(ctx, u.recorder, event, err) + return author.Author{}, err + } + event.Target = authorTarget(created) + u.recorder.Record(ctx, event) + return created, nil +} + +func authorNameOf(caller user.User) string { + name := strings.TrimSpace(caller.DisplayName) + if name == "" { + name = strings.TrimSpace(caller.Username) + } + runes := []rune(name) + return string(runes[:min(len(runes), author.MaxNameRunes)]) +} + +func (u *CreateMyAuthorUseCase) create(caller user.User, name string) (author.Author, error) { + _, err := u.authors.GetByUserID(caller.ID) + if err == nil { + return author.Author{}, author.ErrUserAlreadyLinked + } + if !errors.Is(err, author.ErrAuthorNotFound) { + return author.Author{}, err + } + return u.createWithFreeSlug(caller.ID, name) +} + +func (u *CreateMyAuthorUseCase) createWithFreeSlug(userID uint32, name string) (author.Author, error) { + base := author.GenerateSlug(name) + for attempt := 1; attempt <= maxOwnSlugAttempts; attempt++ { + slug := author.SlugWithSuffix(base, attempt) + taken, err := isSlugTaken(u.authors, slug) + if err != nil { + return author.Author{}, err + } + if taken { + continue + } + created, err := u.authors.Create(author.Author{Name: name, Slug: slug, SocialLinks: []author.SocialLink{}, UserID: &userID, IsLinked: true}) + if errors.Is(err, author.ErrSlugTaken) { + continue + } + return created, err + } + return author.Author{}, author.ErrSlugTaken +} diff --git a/internal/infrastructure/useCases/DeleteAuthor.go b/internal/infrastructure/useCases/DeleteAuthor.go new file mode 100644 index 0000000..fd8f17a --- /dev/null +++ b/internal/infrastructure/useCases/DeleteAuthor.go @@ -0,0 +1,76 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" + "context" +) + +type ReassignMode int + +const ( + ReassignAbsent ReassignMode = iota + ReassignNone + ReassignToAuthor + ReassignMalformed +) + +type Reassignment struct { + Mode ReassignMode + Destination uint32 +} + +type DeleteAuthorUseCase struct { + authors author.Repository + recorder audit.Recorder +} + +func NewDeleteAuthorUseCase(authors author.Repository, recorder audit.Recorder) *DeleteAuthorUseCase { + return &DeleteAuthorUseCase{authors: authors, recorder: recorder} +} + +func (u *DeleteAuthorUseCase) Delete(ctx context.Context, id uint32, reassignment Reassignment) error { + target, err := u.authors.Get(id) + if err != nil { + return err + } + + event := audit.NewEvent(audit.ActionAuthorDelete, audit.CategoryAuthors) + event.Target = authorTarget(target) + if err := u.delete(target, reassignment); err != nil { + recordFailure(ctx, u.recorder, event, err) + return err + } + u.recorder.Record(ctx, event) + return nil +} + +func (u *DeleteAuthorUseCase) delete(target author.Author, reassignment Reassignment) error { + if reassignment.Mode == ReassignNone { + return u.authors.DeleteUncrediting(target.ID) + } + if target.ArticleCount == 0 { + return u.authors.DeleteMovingCredits(target.ID, nil) + } + destination, err := u.destination(target, reassignment) + if err != nil { + return err + } + return u.authors.DeleteMovingCredits(target.ID, &destination) +} + +func (u *DeleteAuthorUseCase) destination(target author.Author, reassignment Reassignment) (uint32, error) { + switch reassignment.Mode { + case ReassignAbsent: + return 0, author.ErrAuthorHasArticles + case ReassignMalformed: + return 0, author.ErrInvalidReassign + } + if reassignment.Destination == 0 || reassignment.Destination == target.ID { + return 0, author.ErrInvalidReassign + } + if _, err := u.authors.Get(reassignment.Destination); err != nil { + return 0, err + } + return reassignment.Destination, nil +} diff --git a/internal/infrastructure/useCases/DeleteMedia.go b/internal/infrastructure/useCases/DeleteMedia.go index b40c4f3..03628fe 100644 --- a/internal/infrastructure/useCases/DeleteMedia.go +++ b/internal/infrastructure/useCases/DeleteMedia.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" "context" @@ -12,10 +13,11 @@ type DeleteMediaUseCase struct { remover mediaRemover recorder audit.Recorder sites site.Repository + authors author.Repository } -func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *DeleteMediaUseCase { - return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites} +func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *DeleteMediaUseCase { + return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors} } func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error { @@ -23,8 +25,14 @@ func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error { if err != nil { return err } + clearedAuthors, err := u.authors.ClearAvatar(img.ID) + if err != nil { + _ = u.sites.AssignImage(clearedSites, img.ID) + return err + } if err := u.remover.remove(img); err != nil { _ = u.sites.AssignImage(clearedSites, img.ID) + _ = u.authors.AssignAvatar(clearedAuthors, img.ID) return err } return nil diff --git a/internal/infrastructure/useCases/GetMyAuthor.go b/internal/infrastructure/useCases/GetMyAuthor.go new file mode 100644 index 0000000..f7df36e --- /dev/null +++ b/internal/infrastructure/useCases/GetMyAuthor.go @@ -0,0 +1,25 @@ +package useCases + +import ( + "RenewCMS/internal/domain/author" + "errors" +) + +type GetMyAuthorUseCase struct { + authors author.Repository +} + +func NewGetMyAuthorUseCase(authors author.Repository) *GetMyAuthorUseCase { + return &GetMyAuthorUseCase{authors: authors} +} + +func (u *GetMyAuthorUseCase) Get(userID uint32) (*author.Author, error) { + found, err := u.authors.GetByUserID(userID) + if errors.Is(err, author.ErrAuthorNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &found, nil +} diff --git a/internal/infrastructure/useCases/ListActivity.go b/internal/infrastructure/useCases/ListActivity.go index 9c651b0..4cba076 100644 --- a/internal/infrastructure/useCases/ListActivity.go +++ b/internal/infrastructure/useCases/ListActivity.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/user" @@ -20,6 +21,7 @@ type ListActivityUseCase struct { apiKeys apikey.Repository now func() time.Time sites site.Repository + authors author.Repository } type ListActivityCommand struct { @@ -39,8 +41,8 @@ type ActivityPage struct { Size int } -func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository) *ListActivityUseCase { - return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites} +func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository, authors author.Repository) *ListActivityUseCase { + return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites, authors: authors} } func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) { @@ -130,6 +132,9 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) { case audit.TargetSite: _, err = l.sites.Get(uint32(id)) return existsUnless(err, site.ErrSiteNotFound) + case audit.TargetAuthor: + _, err = l.authors.Get(uint32(id)) + return existsUnless(err, author.ErrAuthorNotFound) default: return true, nil } diff --git a/internal/infrastructure/useCases/ListAuthors.go b/internal/infrastructure/useCases/ListAuthors.go new file mode 100644 index 0000000..52ecfe4 --- /dev/null +++ b/internal/infrastructure/useCases/ListAuthors.go @@ -0,0 +1,44 @@ +package useCases + +import ( + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/user" +) + +type ListAuthorsUseCase struct { + authors author.Repository + users user.Repository +} + +type ListAuthorsCommand struct { + Q string + Page int + Size int + CallerRole user.Role +} + +type AuthorPage struct { + Items []author.Author + Total int + Page int + Size int +} + +func NewListAuthorsUseCase(authors author.Repository, users user.Repository) *ListAuthorsUseCase { + return &ListAuthorsUseCase{authors: authors, users: users} +} + +func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error) { + found, err := u.authors.List(author.ListQuery{Q: command.Q, Page: command.Page, Size: command.Size}) + if err != nil { + return AuthorPage{}, err + } + items := make([]author.Author, 0, len(found.Items)) + for _, item := range found.Items { + if isAdminRole(command.CallerRole) { + item = withUserEmail(u.users, item) + } + items = append(items, item) + } + return AuthorPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil +} diff --git a/internal/infrastructure/useCases/ListMedia.go b/internal/infrastructure/useCases/ListMedia.go index 1122de0..09b7745 100644 --- a/internal/infrastructure/useCases/ListMedia.go +++ b/internal/infrastructure/useCases/ListMedia.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/user" @@ -38,10 +39,11 @@ type ListMediaUseCase struct { userRepository user.Repository variantFiles image.VariantFileRepository sites site.Repository + authors author.Repository } -func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository) *ListMediaUseCase { - return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites} +func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository, authors author.Repository) *ListMediaUseCase { + return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites, authors: authors} } func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) { @@ -108,7 +110,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) { if err != nil { return MediaItem{}, err } - siteUsage, err := siteImageUsage(l.sites) + siteUsage, err := imageUsageCount(l.sites, l.authors) if err != nil { return MediaItem{}, err } @@ -143,7 +145,7 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) { if err != nil { return nil, err } - siteUsage, err := siteImageUsage(l.sites) + siteUsage, err := imageUsageCount(l.sites, l.authors) if err != nil { return nil, err } diff --git a/internal/infrastructure/useCases/MergeMedia.go b/internal/infrastructure/useCases/MergeMedia.go index 26dd9a2..060af0b 100644 --- a/internal/infrastructure/useCases/MergeMedia.go +++ b/internal/infrastructure/useCases/MergeMedia.go @@ -3,6 +3,7 @@ package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" "cmp" @@ -16,6 +17,7 @@ type MergeMediaResult struct { ArticlesUpdated int Merged int SitesUpdated int + AuthorsUpdated int UpdatedArticleIDs []uint32 } @@ -25,18 +27,27 @@ type MergeMediaUseCase struct { remover mediaRemover recorder audit.Recorder sites site.Repository + authors author.Repository } -func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *MergeMediaUseCase { - return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites} +func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *MergeMediaUseCase { + return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors} } -func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) { +func copyIdsOf(copies []image.Image) []uint32 { copyIds := make([]uint32, 0, len(copies)) for _, duplicate := range copies { copyIds = append(copyIds, duplicate.ID) } - return u.sites.RepointImage(copyIds, keptId) + return copyIds +} + +func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) { + return u.sites.RepointImage(copyIdsOf(copies), keptId) +} + +func (u *MergeMediaUseCase) repointAuthors(copies []image.Image, keptId uint32) (int, error) { + return u.authors.RepointAvatar(copyIdsOf(copies), keptId) } type bodyRewrite struct { @@ -95,13 +106,19 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR return MergeMediaResult{}, err } + authorsUpdated, err := u.repointAuthors(copies, kept.ID) + if err != nil { + u.restoreBodies(rewrites) + return MergeMediaResult{}, err + } + for _, duplicate := range copies { if err := u.remover.remove(duplicate); err != nil { return MergeMediaResult{}, err } } - return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil + return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil } func rewrittenIds(rewrites []bodyRewrite) []uint32 { diff --git a/internal/infrastructure/useCases/PreviewPurge.go b/internal/infrastructure/useCases/PreviewPurge.go index 40ba04f..e6f82c8 100644 --- a/internal/infrastructure/useCases/PreviewPurge.go +++ b/internal/infrastructure/useCases/PreviewPurge.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" ) @@ -29,8 +30,8 @@ type PreviewPurgeUseCase struct { planner *purgePlanner } -func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository) *PreviewPurgeUseCase { - return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}} +func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *PreviewPurgeUseCase { + return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}} } func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) { diff --git a/internal/infrastructure/useCases/RunPurge.go b/internal/infrastructure/useCases/RunPurge.go index 7403805..bb657ab 100644 --- a/internal/infrastructure/useCases/RunPurge.go +++ b/internal/infrastructure/useCases/RunPurge.go @@ -3,6 +3,7 @@ package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/purge" "RenewCMS/internal/domain/site" @@ -23,9 +24,9 @@ type RunPurgeUseCase struct { guard sync.Mutex } -func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository) *RunPurgeUseCase { +func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository, authors author.Repository) *RunPurgeUseCase { return &RunPurgeUseCase{ - planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}, + planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}, merge: merge, repository: repository, now: now, @@ -88,6 +89,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult { result.GroupsPurged++ result.Merged += merged.Merged result.SitesUpdated += merged.SitesUpdated + result.AuthorsUpdated += merged.AuthorsUpdated for _, id := range merged.UpdatedArticleIDs { updated[id] = true } diff --git a/internal/infrastructure/useCases/UpdateArticle.go b/internal/infrastructure/useCases/UpdateArticle.go index 1e34c4b..3ce65e5 100644 --- a/internal/infrastructure/useCases/UpdateArticle.go +++ b/internal/infrastructure/useCases/UpdateArticle.go @@ -3,9 +3,12 @@ package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/site" "context" + "slices" "strconv" + "strings" ) type UpdateArticleUseCase struct { @@ -13,16 +16,18 @@ type UpdateArticleUseCase struct { bodyRenderer article.BodyRenderer recorder audit.Recorder sites site.Repository + authors author.Repository } type UpdateArticleCommand struct { - Body *string - SiteID *uint32 - Format article.BodyFormat + Body *string + SiteID *uint32 + AuthorIDs *[]uint32 + Format article.BodyFormat } -func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *UpdateArticleUseCase { - return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites} +func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *UpdateArticleUseCase { + return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors} } func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) { @@ -38,7 +43,10 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up if err != nil { return article.Article{}, err } - updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID}) + if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil { + return article.Article{}, err + } + updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID, AuthorIDs: command.AuthorIDs}) if err != nil { g.recordFailure(ctx, audit.ActionArticleEdit, before, err) return article.Article{}, err @@ -58,11 +66,51 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s return &destination, nil } +func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error { + if ids == nil { + return nil + } + for _, id := range *ids { + if _, err := g.authors.Get(id); err != nil { + return err + } + } + return nil +} + +func authorsChange(before article.Article, after article.Article) (audit.Change, bool) { + beforeIDs := bylineIDs(before.Authors) + afterIDs := bylineIDs(after.Authors) + if slices.Equal(beforeIDs, afterIDs) { + return audit.Change{}, false + } + return audit.TextChange(audit.FieldAuthors, bylineNames(before.Authors), bylineNames(after.Authors)), true +} + +func bylineIDs(bylines []author.Byline) []uint32 { + ids := make([]uint32, 0, len(bylines)) + for _, byline := range bylines { + ids = append(ids, byline.ID) + } + return ids +} + +func bylineNames(bylines []author.Byline) string { + names := make([]string, 0, len(bylines)) + for _, byline := range bylines { + names = append(names, byline.Name) + } + return strings.Join(names, authorChangeSeparator) +} + func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) { changes := audit.ArticleChanges(before, after) if destination != nil && before.SiteID != after.SiteID { changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name)) } + if change, changed := authorsChange(before, after); changed { + changes = append(changes, change) + } if len(changes) == 0 { return } diff --git a/internal/infrastructure/useCases/UpdateAuthor.go b/internal/infrastructure/useCases/UpdateAuthor.go new file mode 100644 index 0000000..a1d1ce7 --- /dev/null +++ b/internal/infrastructure/useCases/UpdateAuthor.go @@ -0,0 +1,152 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/user" + "context" + "strings" +) + +type UpdateAuthorUseCase struct { + authors author.Repository + users user.Repository + images image.Repository + recorder audit.Recorder +} + +func NewUpdateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *UpdateAuthorUseCase { + return &UpdateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder} +} + +func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller AuthorCaller, patch author.Patch) (author.Author, error) { + before, err := u.authors.Get(id) + if err != nil { + return author.Author{}, err + } + + event := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors) + event.Target = authorTarget(before) + after, err := u.prepare(before, caller, patch) + if err != nil { + recordFailure(ctx, u.recorder, event, err) + return author.Author{}, err + } + + changes := authorChanges(u.users, u.images, before, after) + if len(changes) == 0 { + return u.present(before, caller), nil + } + saved, err := u.authors.Update(after) + if err != nil { + recordFailure(ctx, u.recorder, event, err) + return author.Author{}, err + } + event.Target = authorTarget(saved) + event.Changes = changes + u.recorder.Record(ctx, event) + return u.present(saved, caller), nil +} + +func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author { + if caller.isAdmin() { + return withUserEmail(u.users, a) + } + a.UserEmail = nil + return a +} + +func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller, patch author.Patch) (author.Author, error) { + if err := authorizeEdit(before, caller, patch); err != nil { + return author.Author{}, err + } + after := before + if patch.Name != nil { + after.Name = strings.TrimSpace(*patch.Name) + } + if patch.Bio != nil { + after.Bio = *patch.Bio + } + if patch.Website.Set { + after.Website = patch.Website.Value + } + if patch.SocialLinks != nil { + after.SocialLinks = *patch.SocialLinks + } + website, err := validateAuthorContent(after.Bio, after.Website, after.SocialLinks) + if err != nil { + return author.Author{}, err + } + after.Website = website + if err := u.applySlug(&after, patch); err != nil { + return author.Author{}, err + } + if err := u.applyAvatar(&after, patch); err != nil { + return author.Author{}, err + } + return after, u.applyLink(&after, patch) +} + +func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error { + if caller.isAdmin() { + return nil + } + if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set { + return author.ErrAuthorRights + } + return nil +} + +func (u *UpdateAuthorUseCase) applySlug(after *author.Author, patch author.Patch) error { + if patch.Slug == nil || *patch.Slug == after.Slug { + return nil + } + if !author.IsValidSlug(*patch.Slug) { + return author.ErrInvalidSlug + } + taken, err := isSlugTaken(u.authors, *patch.Slug) + if err != nil { + return err + } + if taken { + return author.ErrSlugTaken + } + after.Slug = *patch.Slug + return nil +} + +func (u *UpdateAuthorUseCase) applyAvatar(after *author.Author, patch author.Patch) error { + if !patch.AvatarID.Set { + return nil + } + if err := ensureImageExists(u.images, patch.AvatarID.Value); err != nil { + return err + } + after.AvatarID = patch.AvatarID.Value + return nil +} + +func (u *UpdateAuthorUseCase) applyLink(after *author.Author, patch author.Patch) error { + if !patch.UserID.Set { + return nil + } + requested := patch.UserID.Value + if requested == nil { + after.UserID = nil + after.IsLinked = false + return nil + } + if after.UserID != nil { + if *after.UserID == *requested { + return nil + } + return author.ErrAuthorAlreadyLinked + } + if err := ensureUserCanBeLinked(u.users, u.authors, *requested); err != nil { + return err + } + after.UserID = requested + after.IsLinked = true + return nil +} diff --git a/internal/infrastructure/useCases/auditFailures.go b/internal/infrastructure/useCases/auditFailures.go index a4c872f..638611b 100644 --- a/internal/infrastructure/useCases/auditFailures.go +++ b/internal/infrastructure/useCases/auditFailures.go @@ -3,6 +3,7 @@ package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/purge" @@ -64,6 +65,12 @@ var failureReasons = []failureReason{ {site.ErrInvalidLocale, "The locale is not valid."}, {site.ErrUseSetDefault, "Use the default site action to change the default."}, {site.ErrInvalidReassign, "The destination site is not valid."}, + {author.ErrSlugTaken, "That slug is already used by another author."}, + {author.ErrUserAlreadyLinked, "That user already has an author."}, + {author.ErrAuthorAlreadyLinked, "That author is already linked to a user."}, + {author.ErrAuthorRights, "Only an owner or admin can change this author."}, + {author.ErrAuthorHasArticles, "The author is still credited on articles."}, + {author.ErrInvalidReassign, "The destination author is not valid."}, } var restartFailureReasons = []failureReason{ @@ -82,6 +89,11 @@ var notFoundCauses = []error{ invite.ErrInviteNotFound, user.ErrUserNotFound, site.ErrSiteNotFound, + author.ErrAuthorNotFound, + author.ErrBioTooLong, + author.ErrInvalidURL, + author.ErrTooManyLinks, + author.ErrInvalidSlug, } func reasonFor(err error, overrides ...failureReason) string { diff --git a/internal/infrastructure/useCases/authorSupport.go b/internal/infrastructure/useCases/authorSupport.go new file mode 100644 index 0000000..12101c3 --- /dev/null +++ b/internal/infrastructure/useCases/authorSupport.go @@ -0,0 +1,115 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/user" + "errors" + "strconv" + "strings" +) + +const authorChangeSeparator = ", " + +type AuthorCaller struct { + UserID uint32 + Role user.Role +} + +func isAdminRole(role user.Role) bool { + return role == user.RoleOwner || role == user.RoleAdmin +} + +func (c AuthorCaller) isAdmin() bool { + return isAdminRole(c.Role) +} + +func authorTarget(a author.Author) audit.Target { + return audit.NewTarget(audit.TargetAuthor, a.ID, a.Name) +} + +func isSlugTaken(authors author.Repository, slug string) (bool, error) { + _, err := authors.GetBySlug(slug) + if errors.Is(err, author.ErrAuthorNotFound) { + return false, nil + } + return err == nil, err +} + +func ensureUserCanBeLinked(users user.Repository, authors author.Repository, userID uint32) error { + if _, err := users.Get(userID); err != nil { + return err + } + _, err := authors.GetByUserID(userID) + if err == nil { + return author.ErrUserAlreadyLinked + } + if errors.Is(err, author.ErrAuthorNotFound) { + return nil + } + return err +} + +func withUserEmail(users user.Repository, a author.Author) author.Author { + a.UserEmail = nil + if a.UserID == nil { + return a + } + linked, err := users.Get(*a.UserID) + if err != nil { + return a + } + email := linked.Email + a.UserEmail = &email + return a +} + +func linksLabel(links []author.SocialLink) string { + labels := make([]string, 0, len(links)) + for _, link := range links { + labels = append(labels, link.Type+": "+link.URL) + } + return strings.Join(labels, authorChangeSeparator) +} + +func websiteLabel(website *string) string { + if website == nil { + return "" + } + return *website +} + +func usernameLabel(users user.Repository, id *uint32) string { + if id == nil { + return "" + } + found, err := users.Get(*id) + if err != nil { + return strconv.FormatUint(uint64(*id), 10) + } + return found.Username +} + +func sameID(a *uint32, b *uint32) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func authorChanges(users user.Repository, images image.Repository, before author.Author, after author.Author) []audit.Change { + changes := []audit.Change{} + changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name) + changes = appendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug) + changes = appendTextChange(changes, audit.FieldBio, before.Bio, after.Bio) + changes = appendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website)) + changes = appendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks)) + if !sameID(before.AvatarID, after.AvatarID) { + changes = append(changes, audit.TextChange(audit.FieldAvatar, imageLabel(images, before.AvatarID), imageLabel(images, after.AvatarID))) + } + if !sameID(before.UserID, after.UserID) { + changes = append(changes, audit.TextChange(audit.FieldUser, usernameLabel(users, before.UserID), usernameLabel(users, after.UserID))) + } + return changes +} diff --git a/internal/infrastructure/useCases/purgePlan.go b/internal/infrastructure/useCases/purgePlan.go index 85ec247..e94b9eb 100644 --- a/internal/infrastructure/useCases/purgePlan.go +++ b/internal/infrastructure/useCases/purgePlan.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" "crypto/sha256" @@ -28,6 +29,7 @@ type purgePlanner struct { imageRepository image.Repository articleRepository article.Repository sites site.Repository + authors author.Repository } func (p *purgePlanner) plan() (purgePlan, error) { @@ -40,7 +42,7 @@ func (p *purgePlanner) plan() (purgePlan, error) { return purgePlan{}, err } - siteUsage, err := siteImageUsage(p.sites) + siteUsage, err := imageUsageCount(p.sites, p.authors) if err != nil { return purgePlan{}, err } diff --git a/internal/infrastructure/useCases/siteImageUsage.go b/internal/infrastructure/useCases/siteImageUsage.go index 9959006..72bcf0c 100644 --- a/internal/infrastructure/useCases/siteImageUsage.go +++ b/internal/infrastructure/useCases/siteImageUsage.go @@ -1,6 +1,9 @@ package useCases -import "RenewCMS/internal/domain/site" +import ( + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/site" +) func siteImageUsage(sites site.Repository) (map[uint32]int, error) { usage := map[uint32]int{} @@ -15,3 +18,18 @@ func siteImageUsage(sites site.Repository) (map[uint32]int, error) { } return usage, nil } + +func imageUsageCount(sites site.Repository, authors author.Repository) (map[uint32]int, error) { + usage, err := siteImageUsage(sites) + if err != nil { + return nil, err + } + avatars, err := authors.AvatarUsage() + if err != nil { + return nil, err + } + for imageID, total := range avatars { + usage[imageID] += total + } + return usage, nil +} diff --git a/test/accept_invitation_atomic_test.go b/test/accept_invitation_atomic_test.go index b205e39..47e5199 100644 --- a/test/accept_invitation_atomic_test.go +++ b/test/accept_invitation_atomic_test.go @@ -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() { diff --git a/test/accept_invitation_test.go b/test/accept_invitation_test.go index b45a5f3..9449ab3 100644 --- a/test/accept_invitation_test.go +++ b/test/accept_invitation_test.go @@ -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"]) } diff --git a/test/activity_domain_test.go b/test/activity_domain_test.go index 5253704..3f99c2f 100644 --- a/test/activity_domain_test.go +++ b/test/activity_domain_test.go @@ -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"}}, diff --git a/test/activity_log_backend_http_test.go b/test/activity_log_backend_http_test.go index d48d2d8..4a53ee7 100644 --- a/test/activity_log_backend_http_test.go +++ b/test/activity_log_backend_http_test.go @@ -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), } diff --git a/test/activity_log_producers_http_test.go b/test/activity_log_producers_http_test.go index 44da320..de2f370 100644 --- a/test/activity_log_producers_http_test.go +++ b/test/activity_log_producers_http_test.go @@ -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 diff --git a/test/activity_log_producers_test.go b/test/activity_log_producers_test.go index 887dceb..97b1fd9 100644 --- a/test/activity_log_producers_test.go +++ b/test/activity_log_producers_test.go @@ -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() { diff --git a/test/activity_usecases_test.go b/test/activity_usecases_test.go index b070ed0..5dc3f5a 100644 --- a/test/activity_usecases_test.go +++ b/test/activity_usecases_test.go @@ -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 diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 3412c55..6828c0f 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -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), diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go index b286a67..b3d2603 100644 --- a/test/article_body_format_test.go +++ b/test/article_body_format_test.go @@ -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, ) diff --git a/test/article_body_unlimited_http_test.go b/test/article_body_unlimited_http_test.go index c66c619..2acfa0d 100644 --- a/test/article_body_unlimited_http_test.go +++ b/test/article_body_unlimited_http_test.go @@ -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, ) diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go index 02f4487..421b37d 100644 --- a/test/article_publishing_http_test.go +++ b/test/article_publishing_http_test.go @@ -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, ) diff --git a/test/article_test.go b/test/article_test.go index 9ab6b8e..d3fe490 100644 --- a/test/article_test.go +++ b/test/article_test.go @@ -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"} diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index 01209c4..7de7250 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -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"}) diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go index 473cea5..9c63491 100644 --- a/test/articles_list_http_test.go +++ b/test/articles_list_http_test.go @@ -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, ) diff --git a/test/author_domain_test.go b/test/author_domain_test.go new file mode 100644 index 0000000..3c79f92 --- /dev/null +++ b/test/author_domain_test.go @@ -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) + } + }) +} diff --git a/test/author_mutation_test.go b/test/author_mutation_test.go new file mode 100644 index 0000000..de3a1d7 --- /dev/null +++ b/test/author_mutation_test.go @@ -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) + } +} diff --git a/test/author_persistence_test.go b/test/author_persistence_test.go new file mode 100644 index 0000000..7fde41e --- /dev/null +++ b/test/author_persistence_test.go @@ -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) + } + }) +} diff --git a/test/author_support_test.go b/test/author_support_test.go new file mode 100644 index 0000000..0e5e211 --- /dev/null +++ b/test/author_support_test.go @@ -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} +} diff --git a/test/authors_backend_activity_http_test.go b/test/authors_backend_activity_http_test.go new file mode 100644 index 0000000..6a15c05 --- /dev/null +++ b/test/authors_backend_activity_http_test.go @@ -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.") + }) +} diff --git a/test/authors_backend_articles_http_test.go b/test/authors_backend_articles_http_test.go new file mode 100644 index 0000000..f1aaa97 --- /dev/null +++ b/test/authors_backend_articles_http_test.go @@ -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()) + } + }) +} diff --git a/test/authors_backend_edit_http_test.go b/test/authors_backend_edit_http_test.go new file mode 100644 index 0000000..4227195 --- /dev/null +++ b/test/authors_backend_edit_http_test.go @@ -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") + } + }) +} diff --git a/test/authors_backend_http_test.go b/test/authors_backend_http_test.go new file mode 100644 index 0000000..86c8c96 --- /dev/null +++ b/test/authors_backend_http_test.go @@ -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") + } + }) +} diff --git a/test/container_update_guidance_http_test.go b/test/container_update_guidance_http_test.go index cc46d2c..c64d8eb 100644 --- a/test/container_update_guidance_http_test.go +++ b/test/container_update_guidance_http_test.go @@ -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, diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go index 724574b..6e08549 100644 --- a/test/edit_article_body_http_test.go +++ b/test/edit_article_body_http_test.go @@ -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, ) diff --git a/test/get_current_user_http_test.go b/test/get_current_user_http_test.go index 9ea27ab..96bc57c 100644 --- a/test/get_current_user_http_test.go +++ b/test/get_current_user_http_test.go @@ -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), diff --git a/test/image_test.go b/test/image_test.go index bbdad2b..e358d9f 100644 --- a/test/image_test.go +++ b/test/image_test.go @@ -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{ diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index 3dbef51..1ce1041 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -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()), } } diff --git a/test/media_cleanup_test.go b/test/media_cleanup_test.go index 5f906ad..259f1b7 100644 --- a/test/media_cleanup_test.go +++ b/test/media_cleanup_test.go @@ -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) diff --git a/test/media_env_test.go b/test/media_env_test.go index d7fd35e..87e0853 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -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{})) diff --git a/test/media_library_http_test.go b/test/media_library_http_test.go index 5ecd4a4..74644c3 100644 --- a/test/media_library_http_test.go +++ b/test/media_library_http_test.go @@ -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) diff --git a/test/media_pagination_http_test.go b/test/media_pagination_http_test.go index 8d71c73..6fc476d 100644 --- a/test/media_pagination_http_test.go +++ b/test/media_pagination_http_test.go @@ -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++ { diff --git a/test/media_persistence_test.go b/test/media_persistence_test.go index 6901d69..9cc92b7 100644 --- a/test/media_persistence_test.go +++ b/test/media_persistence_test.go @@ -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() diff --git a/test/media_purge_http_test.go b/test/media_purge_http_test.go index 95f0539..7939101 100644 --- a/test/media_purge_http_test.go +++ b/test/media_purge_http_test.go @@ -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) { diff --git a/test/media_purge_scheduler_test.go b/test/media_purge_scheduler_test.go index c34d051..5210670 100644 --- a/test/media_purge_scheduler_test.go +++ b/test/media_purge_scheduler_test.go @@ -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, diff --git a/test/media_purge_test.go b/test/media_purge_test.go index 5dd92ac..e0c5c6b 100644 --- a/test/media_purge_test.go +++ b/test/media_purge_test.go @@ -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}) diff --git a/test/media_storage_orchestration_test.go b/test/media_storage_orchestration_test.go index c25c52b..901aabf 100644 --- a/test/media_storage_orchestration_test.go +++ b/test/media_storage_orchestration_test.go @@ -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) diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index c3ba358..353ddfd 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -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() diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index 55e02d2..5627cb6 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -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, ) diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index a9e3d04..8eebdb7 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -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, ) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 826c3e4..d235af9 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -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), } } diff --git a/test/site_articles_acceptance_test.go b/test/site_articles_acceptance_test.go index c70fe84..5b38a46 100644 --- a/test/site_articles_acceptance_test.go +++ b/test/site_articles_acceptance_test.go @@ -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) diff --git a/test/site_domain_test.go b/test/site_domain_test.go index 3570aab..e063570 100644 --- a/test/site_domain_test.go +++ b/test/site_domain_test.go @@ -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}) diff --git a/test/site_support_test.go b/test/site_support_test.go index 01d63d7..4bc1262 100644 --- a/test/site_support_test.go +++ b/test/site_support_test.go @@ -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) diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index 83e2ae9..491300d 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -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 } diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index 75cb45f..dddea30 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -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( diff --git a/test/storage_seam_test.go b/test/storage_seam_test.go index 47f2915..fb98b51 100644 --- a/test/storage_seam_test.go +++ b/test/storage_seam_test.go @@ -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") diff --git a/test/update_download_http_test.go b/test/update_download_http_test.go index b1bd5f9..d99323d 100644 --- a/test/update_download_http_test.go +++ b/test/update_download_http_test.go @@ -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, diff --git a/test/update_download_test.go b/test/update_download_test.go index ab8ab43..414cce2 100644 --- a/test/update_download_test.go +++ b/test/update_download_test.go @@ -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"} { diff --git a/test/update_http_test.go b/test/update_http_test.go index 91faa6c..542a883 100644 --- a/test/update_http_test.go +++ b/test/update_http_test.go @@ -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), } diff --git a/test/update_restart_http_test.go b/test/update_restart_http_test.go index 066292a..7ec4d14 100644 --- a/test/update_restart_http_test.go +++ b/test/update_restart_http_test.go @@ -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( diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index d561e74..385a64a 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -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) { diff --git a/test/user_display_name_test.go b/test/user_display_name_test.go index 61c64a6..07db31c 100644 --- a/test/user_display_name_test.go +++ b/test/user_display_name_test.go @@ -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") } diff --git a/test/user_roles_test.go b/test/user_roles_test.go index 6d13231..f776271 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -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, diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index 16c3ebd..20ab95d 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -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, ) diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index 2064d8f..06e12db 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -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, )