feat: Authors feature backend (and upgraded deprecated mods)

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

No files matched your search

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