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