feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

+9 -1
View File
@@ -59,6 +59,9 @@ type UseCases struct {
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
ListSitemapUseCase *useCases.ListSitemapUseCase ListSitemapUseCase *useCases.ListSitemapUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase
CreateTranslationUseCase *useCases.CreateTranslationUseCase
DescribeArticleUseCase *useCases.DescribeArticleUseCase
MoveStoryUseCase *useCases.MoveStoryUseCase
ListAuthorsUseCase *useCases.ListAuthorsUseCase ListAuthorsUseCase *useCases.ListAuthorsUseCase
CreateAuthorUseCase *useCases.CreateAuthorUseCase CreateAuthorUseCase *useCases.CreateAuthorUseCase
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
@@ -385,6 +388,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now) usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker) serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
moveStory := useCases.NewMoveStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder)
return &UseCases{ return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
@@ -393,8 +398,11 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo, repos.LocalizationRepo, moveStory),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
MoveStoryUseCase: moveStory,
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo), ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo), CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo), ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
+164 -123
View File
@@ -3,11 +3,10 @@ package article
import ( import (
"RenewCMS/api/pkg/access" "RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/articleerr"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -26,25 +25,40 @@ import (
) )
type Handler struct { type Handler struct {
CreateUseCase *useCases.CreateArticleUseCase createUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase getUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase listUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase deleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase updateUseCase *useCases.UpdateArticleUseCase
translationUseCase *useCases.CreateTranslationUseCase
describeUseCase *useCases.DescribeArticleUseCase
} }
type PostArticle struct { type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"` Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body"` Body string `json:"body"`
Lang *string `json:"lang"`
StoryID *uint32 `json:"story_id" validate:"omitempty,gte=1"`
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
Copy *bool `json:"copy"`
} }
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler { type PostTranslation struct {
Lang string `json:"lang" validate:"required"`
Title string `json:"title" validate:"required,min=3,max=200"`
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
Copy *bool `json:"copy" validate:"required"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, translation *useCases.CreateTranslationUseCase, describe *useCases.DescribeArticleUseCase) *Handler {
return &Handler{ return &Handler{
CreateUseCase: create, createUseCase: create,
GetUseCase: get, getUseCase: get,
ListUseCase: list, listUseCase: list,
DeleteUseCase: delete, deleteUseCase: delete,
UpdateUseCase: update, updateUseCase: update,
translationUseCase: translation,
describeUseCase: describe,
} }
} }
@@ -61,22 +75,26 @@ type baseArticleResponse struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Lang string `json:"lang"` Lang string `json:"lang"`
StoryID uint32 `json:"story_id"` StoryID uint32 `json:"story_id"`
Revision uint32 `json:"revision"`
} }
type articleResponse struct { type articleResponse struct {
baseArticleResponse baseArticleResponse
Slug string `json:"slug"` Slug string `json:"slug"`
Excerpt string `json:"excerpt"` Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"` SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"` SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"` CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"` CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"` SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"` SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"` SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"` CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"` Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"` SEODismissed []string `json:"seo_dismissed"`
Capabilities []string `json:"capabilities"`
Translations []domain.Translation `json:"translations"`
Source *domain.Source `json:"source"`
} }
type listItemResponse struct { type listItemResponse struct {
@@ -121,10 +139,11 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
Lang: article.Lang, Lang: article.Lang,
StoryID: article.StoryID, StoryID: article.StoryID,
Revision: article.EditRevision,
}, nil }, nil
} }
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) { func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) (articleResponse, error) {
base, err := toBaseResponse(article, format, viewer) base, err := toBaseResponse(article, format, viewer)
if err != nil { if err != nil {
return articleResponse{}, err return articleResponse{}, err
@@ -147,6 +166,9 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
CanonicalURL: article.CanonicalURL, CanonicalURL: article.CanonicalURL,
Noindex: article.Noindex, Noindex: article.Noindex,
SEODismissed: dismissed, SEODismissed: dismissed,
Capabilities: described.Capabilities,
Translations: described.Translations,
Source: described.Source,
}, nil }, nil
} }
@@ -158,8 +180,8 @@ func toListItemResponse(article domain.Article, summary domain.SEOSummary, forma
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
} }
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) { func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) ([]byte, error) {
response, err := toArticleResponse(article, format, viewer) response, err := toArticleResponse(article, format, viewer, described)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -192,25 +214,13 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r)) article, err := h.getUseCase.GetArticle(id, format, access.CallerOf(r))
if err != nil { if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { writeArticleErrorResponse(w, err)
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
articleJson, err := encodeArticle(article, format, access.CallerOf(r)) h.writeArticle(w, r, http.StatusOK, article, format, printToStdout)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(articleJson); err != nil {
fmt.Printf("Failed to write response: %v", err)
}
} }
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
@@ -230,24 +240,89 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{ if localArticle.StoryID != nil {
h.postTranslation(w, r, format, localArticle)
return
}
if localArticle.SourceArticleID != nil || localArticle.Copy != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
lang := ""
if localArticle.Lang != nil {
lang = *localArticle.Lang
}
createdArticle, err := h.createUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
Title: localArticle.Title, Title: localArticle.Title,
Body: localArticle.Body, Body: localArticle.Body,
Format: format, Format: format,
Lang: lang,
Caller: access.CallerOf(r), Caller: access.CallerOf(r),
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return
}
if !writeKnownArticleError(w, err) { if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to create article", http.StatusInternalServerError) http.Error(w, "Failed to create article", http.StatusInternalServerError)
} }
return return
} }
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format) h.writeArticle(w, r, http.StatusCreated, createdArticle, format, log.Printf)
}
func (h *Handler) postTranslation(w http.ResponseWriter, r *http.Request, format domain.BodyFormat, request PostArticle) {
if request.Lang == nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
h.createTranslation(w, r, useCases.CreateTranslationCommand{
StoryID: *request.StoryID,
Lang: *request.Lang,
Title: request.Title,
Copy: request.Copy,
SourceArticleID: request.SourceArticleID,
Format: format,
Caller: access.CallerOf(r),
})
}
func (h *Handler) PostTranslation(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var request PostTranslation
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
}
h.createTranslation(w, r, useCases.CreateTranslationCommand{
ArticleID: id,
Lang: request.Lang,
Title: request.Title,
Copy: request.Copy,
SourceArticleID: request.SourceArticleID,
Format: format,
Caller: access.CallerOf(r),
})
}
func (h *Handler) createTranslation(w http.ResponseWriter, r *http.Request, command useCases.CreateTranslationCommand) {
created, err := h.translationUseCase.CreateTranslation(r.Context(), command)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
h.writeArticle(w, r, http.StatusCreated, created, command.Format, log.Printf)
} }
func writeArticleErrorResponse(w http.ResponseWriter, err error) { func writeArticleErrorResponse(w http.ResponseWriter, err error) {
@@ -257,46 +332,13 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
} }
func writeKnownArticleError(w http.ResponseWriter, err error) bool { func writeKnownArticleError(w http.ResponseWriter, err error) bool {
if access.WriteIfMissingPermission(w, err) { return access.WriteIfMissingPermission(w, err) || articleerr.Write(w, err)
return true
}
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, site.ErrSiteNotFound) {
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, author.ErrAuthorNotFound) {
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, domain.ErrSlugTaken) {
writeSlugTaken(w, err)
return true
}
if errors.Is(err, domain.ErrInvalidSlug) {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return true
}
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
return true
}
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
return true
}
return false
} }
func writeSlugTaken(w http.ResponseWriter, err error) { var bodyMismatchFailures = map[string]string{
var conflict *domain.SlugTakenError "Copy": "required",
if errors.As(err, &conflict) && conflict.Suggestion != "" { "IfRevision": "min",
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion) "AuthorIDs": "unique",
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
} }
func writeValidationError(w http.ResponseWriter, err error) { func writeValidationError(w http.ResponseWriter, err error) {
@@ -308,12 +350,26 @@ func writeValidationError(w http.ResponseWriter, err error) {
return return
} }
} }
if len(failures) > 0 && bodyMismatchFailures[failures[0].Field()] == failures[0].Tag() {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
} }
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) { func printToStdout(format string, args ...any) {
articleJson, err := encodeArticle(article, format, access.CallerOf(r)) fmt.Printf(format, args...)
}
func (h *Handler) writeArticle(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat, reportFailure func(string, ...any)) {
caller := access.CallerOf(r)
described, err := h.describeUseCase.Describe(article, caller)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
articleJson, err := encodeArticle(article, format, caller, described)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -323,7 +379,7 @@ func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int
w.WriteHeader(status) w.WriteHeader(status)
} }
if _, err := w.Write(articleJson); err != nil { if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err) reportFailure("Failed to write response: %v", err)
} }
} }
@@ -339,13 +395,13 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format) updatedArticle, err := h.updateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format) h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
} }
type optionalImageID struct { type optionalImageID struct {
@@ -372,9 +428,11 @@ func (o optionalImageID) toNullable() domain.Nullable[uint32] {
} }
type UpdateArticleBody struct { type UpdateArticleBody struct {
Title *string `json:"title"`
IfRevision *uint32 `json:"if_revision" validate:"omitempty,min=1"`
Body *string `json:"body"` Body *string `json:"body"`
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"` SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"` AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,unique,dive,gte=1"`
Slug *string `json:"slug" validate:"omitempty,articleslug"` Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"` Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"` SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
@@ -386,24 +444,13 @@ type UpdateArticleBody struct {
SocialImageID optionalImageID `json:"social_image_id"` SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"` CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"` Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"` SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
} }
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"} var nonNullableArticleFields = []string{"title", "body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
var nullableArticleFields = []string{"cover_image_id", "social_image_id"} var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
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 countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) { func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
known := 0 known := 0
for name, value := range fields { for name, value := range fields {
@@ -430,16 +477,13 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
return UpdateArticleBody{}, false return UpdateArticleBody{}, false
} }
known, ok := countKnownFields(fields, nonNullableArticleFields) known, ok := countKnownFields(fields, nonNullableArticleFields)
if !ok || known == 0 { if !ok || known == 0 || string(fields["if_revision"]) == "null" {
return UpdateArticleBody{}, false return UpdateArticleBody{}, false
} }
var request UpdateArticleBody var request UpdateArticleBody
if err := json.Unmarshal(raw, &request); err != nil { if err := json.Unmarshal(raw, &request); err != nil {
return UpdateArticleBody{}, false return UpdateArticleBody{}, false
} }
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
return UpdateArticleBody{}, false
}
return request, true return request, true
} }
@@ -466,7 +510,9 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{ updatedArticle, err := h.updateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
Title: request.Title,
IfRevision: request.IfRevision,
Body: request.Body, Body: request.Body,
SiteID: request.SiteID, SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs, AuthorIDs: request.AuthorIDs,
@@ -489,7 +535,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format) h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
} }
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -581,7 +627,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
return return
} }
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{ page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format, Format: format,
Caller: access.CallerOf(r), Caller: access.CallerOf(r),
Page: query.Page, Page: query.Page,
@@ -625,15 +671,10 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil { if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if !writeKnownArticleError(w, err) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return
} }
if access.WriteIfMissingPermission(w, err) {
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return return
} }
+1 -1
View File
@@ -34,7 +34,7 @@ type CheckArticleSEOBody struct {
SocialImageID optionalImageID `json:"social_image_id"` SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"` CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"` Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"` SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
} }
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"} var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
+69
View File
@@ -0,0 +1,69 @@
package story
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/articleerr"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
MoveUseCase *useCases.MoveStoryUseCase
}
func NewHandler(move *useCases.MoveStoryUseCase) *Handler {
return &Handler{MoveUseCase: move}
}
type moveRequest struct {
Site *string `json:"site"`
}
type moveResponse struct {
StoryID uint32 `json:"story_id"`
Site string `json:"site"`
SiteID uint32 `json:"site_id"`
ArticleIDs []uint32 `json:"article_ids"`
}
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request moveRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Site == nil || *request.Site == "" {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: uint32(id), SiteKey: *request.Site, Caller: access.CallerOf(r)})
if err != nil {
writeError(w, err)
return
}
body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
if articleerr.Write(w, err) {
return
}
http.Error(w, "Failed to move story", http.StatusInternalServerError)
}
+15
View File
@@ -155,3 +155,18 @@ const EmailUnavailableErrorCode = "email_unavailable"
const LastAdminErrorCode = "last_admin" const LastAdminErrorCode = "last_admin"
const AccountNotDeletableErrorCode = "account_not_deletable" const AccountNotDeletableErrorCode = "account_not_deletable"
const LastRoleHeader = "X-Last-Role" const LastRoleHeader = "X-Last-Role"
const RevisionConflictErrorMessage = "revision_conflict"
const CurrentRevisionHeader = "X-Current-Revision"
const LanguageUnavailableErrorMessage = "language_unavailable"
const LanguageExistsErrorMessage = "language_exists"
const ExistingArticleHeader = "X-Existing-Article-Id"
const LanguageNotEnabledErrorMessage = "language_not_enabled"
const MissingLanguagesHeader = "X-Missing-Languages"
const LanguageDisabledErrorMessage = "language_disabled"
const SourceUnavailableErrorMessage = "source_unavailable"
const SourceOtherStoryErrorMessage = "source_other_story"
const SlugConflictErrorMessage = "slug_conflict"
const ConflictingArticlesHeader = "X-Conflicting-Articles"
const StoryMoveRequiredErrorMessage = "story_move_required"
const StoryBusyErrorMessage = "story_busy"
+99
View File
@@ -0,0 +1,99 @@
package articleerr
import (
"RenewCMS/api/pkg/apperr"
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"errors"
"net/http"
"slices"
"strconv"
"strings"
)
func Write(w http.ResponseWriter, err error) bool {
var conflict *domain.RevisionConflictError
var existing *domain.LanguageExistsError
var notEnabled *domain.LanguageNotEnabledError
var addresses *domain.SlugConflictError
var taken *domain.SlugTakenError
switch {
case errors.As(err, &conflict):
w.Header().Set(apperr.CurrentRevisionHeader, strconv.FormatUint(uint64(conflict.Current), 10))
http.Error(w, apperr.RevisionConflictErrorMessage, http.StatusConflict)
case errors.As(err, &existing):
w.Header().Set(apperr.ExistingArticleHeader, strconv.FormatUint(uint64(existing.ArticleID), 10))
http.Error(w, apperr.LanguageExistsErrorMessage, http.StatusConflict)
case errors.As(err, &notEnabled):
if len(notEnabled.Missing) > 0 {
w.Header().Set(apperr.MissingLanguagesHeader, strings.Join(notEnabled.Missing, ","))
}
http.Error(w, apperr.LanguageNotEnabledErrorMessage, http.StatusUnprocessableEntity)
case errors.As(err, &addresses):
if len(addresses.ArticleIDs) > 0 {
w.Header().Set(apperr.ConflictingArticlesHeader, joinIDs(addresses.ArticleIDs))
}
http.Error(w, apperr.SlugConflictErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSlugTaken):
if errors.As(err, &taken) && taken.Suggestion != "" {
w.Header().Set(apperr.SlugSuggestionHeader, taken.Suggestion)
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
default:
return writeSentinel(w, err)
}
return true
}
func joinIDs(ids []uint32) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, strconv.FormatUint(uint64(id), 10))
}
return strings.Join(parts, ",")
}
type sentinelResponse struct {
cause error
message string
status int
}
var articleSentinels = []sentinelResponse{
{domain.ErrInvalidTitle, apperr.ArticleTitleErrorMessage, http.StatusBadRequest},
{domain.ErrArticleNotFound, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound},
{site.ErrSiteNotFound, apperr.SiteNotFoundErrorMessage, http.StatusNotFound},
{author.ErrAuthorNotFound, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound},
{domain.ErrInvalidSlug, apperr.InvalidSlugErrorMessage, http.StatusBadRequest},
{domain.ErrInvalidCanonicalURL, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity},
{image.ErrImageNotFound, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest},
}
var localizationSentinels = []sentinelResponse{
{domain.ErrLanguageUnavailable, apperr.LanguageUnavailableErrorMessage, http.StatusConflict},
{domain.ErrLanguageTaken, apperr.LanguageUnavailableErrorMessage, http.StatusConflict},
{domain.ErrLanguageDisabled, apperr.LanguageDisabledErrorMessage, http.StatusUnprocessableEntity},
{domain.ErrSourceUnavailable, apperr.SourceUnavailableErrorMessage, http.StatusNotFound},
{domain.ErrSourceOtherStory, apperr.SourceOtherStoryErrorMessage, http.StatusUnprocessableEntity},
{domain.ErrInvalidTranslation, apperr.BodyErrorMessage, http.StatusBadRequest},
{domain.ErrStoryMoveRequired, apperr.StoryMoveRequiredErrorMessage, http.StatusConflict},
{localization.ErrSetupRequired, apperr.SetupRequiredErrorMessage, http.StatusConflict},
{language.ErrInvalidLanguage, apperr.InvalidLanguageErrorMessage, http.StatusUnprocessableEntity},
{language.ErrUnsupportedLanguage, apperr.UnsupportedLanguageErrorMessage, http.StatusUnprocessableEntity},
{transaction.ErrLockContention, apperr.StoryBusyErrorMessage, http.StatusConflict},
}
func writeSentinel(w http.ResponseWriter, err error) bool {
for _, candidate := range slices.Concat(articleSentinels, localizationSentinels) {
if errors.Is(err, candidate.cause) {
http.Error(w, candidate.message, candidate.status)
return true
}
}
return false
}
+7
View File
@@ -7,6 +7,7 @@ import (
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"reflect" "reflect"
"regexp" "regexp"
"slices"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
@@ -19,6 +20,7 @@ const AuthorNameTag = "authorname"
const AuthorSlugTag = "authorslug" const AuthorSlugTag = "authorslug"
const SocialTypeTag = "socialtype" const SocialTypeTag = "socialtype"
const ArticleSlugTag = "articleslug" const ArticleSlugTag = "articleslug"
const SEODismissedTag = "seodismissed"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`) var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -34,6 +36,7 @@ func InitValidator() {
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true) _ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true) _ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true) _ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
_ = Validate.RegisterValidation(SEODismissedTag, isSEODismissed)
} }
func isArticleSlug(field validator.FieldLevel) bool { func isArticleSlug(field validator.FieldLevel) bool {
@@ -44,6 +47,10 @@ func isArticleSlug(field validator.FieldLevel) bool {
return value == "" || article.IsValidSlug(value) return value == "" || article.IsValidSlug(value)
} }
func isSEODismissed(field validator.FieldLevel) bool {
return slices.Contains(article.DismissCodes(), field.Field().String())
}
func isAbsent(field validator.FieldLevel) bool { func isAbsent(field validator.FieldLevel) bool {
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil() return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
} }
+20 -1
View File
@@ -20,6 +20,7 @@ import (
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site" "RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/story"
"RenewCMS/api/handlers/update" "RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user" "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware" "RenewCMS/api/middleware"
@@ -95,7 +96,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase) articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.CreateTranslationUseCase, container.DescribeArticleUseCase)
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase) seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
@@ -110,6 +111,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle) r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
r.With(container.requireArticle(role.KeyContentCreate)).Post("/{id}/translations", articleHandler.PostTranslation)
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle) r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody) r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle) r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
@@ -119,6 +121,22 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
return r return r
} }
func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
storyHandler := story.NewHandler(container.MoveStoryUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.requireArticle(role.KeyContentUpdate)).Post("/{story_id}/move", storyHandler.Move)
})
return r
}
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -607,6 +625,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
}) })
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
+6 -6
View File
@@ -3,7 +3,7 @@
The full, searchable reference is the Integration page of the running app (`/integration`). It The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page, readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
[api/articles.md](api/articles.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md). [api/articles.md](api/articles.md), versions, translations and the story move have [api/article-localization.md](api/article-localization.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
## Base URL ## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`, The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
@@ -28,10 +28,10 @@ for example `https://cms.example/v1/article/`.
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks `Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
cross-site request forgery with the cookie; scripts on other origins must send their key. cross-site request forgery with the cookie; scripts on other origins must send their key.
## Routes a key can call (30) ## Routes a key can call (32)
| Group | Routes | | Group | Routes |
| --- | --- | | --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}, POST /v1/article/{id}/translations, POST /v1/story/{story_id}/move |
| Media | GET /v1/media/, GET /v1/media/{id}, 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) | | Media | GET /v1/media/, GET /v1/media/{id}, 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/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) | | Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings | | Settings | GET /v1/settings |
@@ -161,8 +161,8 @@ A site also carries `site_name` (at most 100 characters, `""` by default), `titl
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>` Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`; keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the `counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO body-mismatch message. `PATCH /v1/article/{id}` takes `title`, `body`, `site_id`, `author_ids`, the SEO
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without fields below and `if_revision`, and nothing else: absent fields are untouched, an empty object, an object without
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never 400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400. changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
@@ -580,7 +580,7 @@ caller's role, which is read on every request (a key follows its creator's role)
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` | | `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 | | `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft | | `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller | | `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller, then the article must be readable by the caller, else 404 |
| `DELETE /v1/article/{id}` | `content.delete` | same | | `DELETE /v1/article/{id}` | `content.delete` | same |
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` | | `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry | | `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
+196
View File
@@ -0,0 +1,196 @@
# Article versions, translations, revisions and story move
Contract for `features/article_translation_create.feature`, `features/article_edit_revision.feature` and `features/article_story_lifecycle.feature`. The SPA mocks `/v1` from this page. The article object itself is described in [articles.md](articles.md), the languages of a site in [site-languages.md](site-languages.md).
## Vocabulary
- A **story** groups the language versions of one article inside one site. A **version** is one article row: it has its own id, `lang`, title, slug, content, publication and `created_by`. A story holds at most one live version per language.
- `revision` is the edit counter of a version, starting at `1`, raised by `1` per saved change (see [What raises revision](#what-raises-revision)). An internal content counter is also kept; it is never exposed on a version, only as `source.revision`.
- A **reader** of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose `content.read` covers it (`all`, or `own` and the caller created it).
- **Missing means no readable version.** Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.
## Errors
Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.
| Header | Set on | Value |
| --- | --- | --- |
| `X-Current-Revision` | `409 revision_conflict` | the current `revision` of the version, an integer |
| `X-Existing-Article-Id` | `409 language_exists` | id of the readable version that already holds the language |
| `X-Conflicting-Articles` | `409 slug_conflict` | ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable |
| `X-Missing-Languages` | `422 language_not_enabled` on a move or on `PATCH site_id` | tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty |
| `X-Slug-Suggestion` | `409 slug_taken` | first free `<slug>-N` (existing) |
| `X-Missing-Permission` | `403 missing_permission` | the permission key (existing) |
The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (`Access-Control-Expose-Headers` is unchanged).
| Status | Body | Meaning |
| --- | --- | --- |
| 400 | `The request cannot be processed due to a mismatch in the format of the body.` | undecodable or invalid body, missing `copy`, `copy: false` with a source, `if_revision` invalid, an unknown or empty update |
| 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | invalid id in the path |
| 400 | the validator text (for example `Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag`) | `lang` or `title` missing, title shorter than 3 or longer than 200 characters on a create |
| 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes |
| 400 | `invalid_slug` | a `PATCH` slug that is not a slug |
| 401 | `You must be logged in to access this resource.` | no valid session or key |
| 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`) |
| 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it |
| 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both |
| 404 | `site_not_found` | `POST /v1/story/{story_id}/move` with an unknown site key |
| 409 | `revision_conflict` | `if_revision` is not the current `revision` (`X-Current-Revision`) |
| 409 | `language_exists` | the language already has a readable version (`X-Existing-Article-Id`) |
| 409 | `language_unavailable` | one neutral answer: the language is disabled on the site, or its version exists but is not readable by the caller, or a concurrent request created it first |
| 409 | `setup_required` | the version has no language yet (`lang` `""`): the site default is not confirmed |
| 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) |
| 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) |
| 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions |
| 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
| 422 | `invalid_language` | the tag is not a language tag |
| 422 | `unsupported_language` | a valid tag outside the supported list |
| 422 | `language_not_enabled` | the site has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, `X-Missing-Languages`) |
| 422 | `language_disabled` | publishing an offline version whose language is disabled on its site |
| 422 | `source_other_story` | the source belongs to another story |
## Fields added to an article read
Added to the article object of `GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`, and for `revision` also to each item of `GET /v1/article/`. Every other key keeps its name, value and type.
```json
{
"id": 10,
"lang": "en",
"story_id": 5,
"revision": 3,
"capabilities": ["read", "edit", "publish", "delete", "create_translation"],
"translations": [
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]},
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
],
"source": {"article_id": 10, "revision": 4}
}
```
- `revision` is a positive integer on every read and write, public reads included.
- `capabilities` lists what the caller may do with this version, always in this order and always starting with `read`: `edit` (`content.update` covers it), `publish` (`content.publish`), `delete` (`content.delete`), `create_translation` (the caller holds `content.create`). An anonymous caller gets `["read"]`.
- `translations` lists the readable versions of the story, the version being read included, with `id`, `lang`, `title`, `is_online`, `language_enabled` and their own `capabilities`. A version without language (`lang` `""`) reports `language_enabled: true`. The order is the default language of the site first, then the other tags in ascending order (`en`, `ar`, `de`, `fr`); a disabled language is still listed with `language_enabled: false`. A single-version article lists itself. An anonymous caller gets `[]`.
- `source` is `{"article_id", "revision"}` when the version was created by copying another version and that source still exists and is readable by the caller, otherwise `null` (a version created empty, a deleted source, an unreadable source, an anonymous caller). `source.revision` is the content revision of the source at the moment of the copy.
## What raises revision
A `PATCH` raises `revision` by exactly `1` when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.
| Changed field | `revision` | internal content revision |
| --- | --- | --- |
| `title`, `body`, `excerpt`, `slug`, `cover_image_alt`, `seo_title`, `seo_description`, `social_title`, `social_description` | +1 | +1 |
| `author_ids`, `cover_image_id`, `social_image_id`, `canonical_url`, `noindex`, `seo_dismissed`, `site_id` | +1 | unchanged |
Existing rows start at revision `1`. A story move raises each moved version by `1`.
## Editing: `PATCH /v1/article/{id}`
Verified session or API key with `content.update` on the version (`403 missing_permission` otherwise, checked first) and able to read it (`404 Article not found` otherwise, checked second), so a stale `if_revision` never reveals anything to a caller who cannot edit or read the version.
- `title`: 3 to 200 runes (a 4-byte emoji counts as one). `""`, 2 and 201 runes answer `400 Article title must be between 3 and 200 characters.`; `null` answers `400` with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.
- `slug`: changed only when sent. `""` regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers `409 slug_taken` with `X-Slug-Suggestion`. Sending the current slug changes nothing.
- `if_revision`: optional integer `>= 1`. A value that is `0`, negative, a string, a fraction or `null` answers `400` (body mismatch). `if_revision` alone is not an update (`400`). When it differs from the current `revision`, nothing is stored and the answer is `409 revision_conflict` with `X-Current-Revision`, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the same `if_revision`, one answers `200` and the other `409`. Without `if_revision` the last write wins. To overwrite after a conflict, send `if_revision` equal to the `X-Current-Revision` the conflict showed; if somebody saved in between, the answer is `409` again with the newer revision.
- `site_id` on a version whose story holds several versions the caller can read answers `409 story_move_required` and changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story, `content.update` is required on every version of the story, readable or not (`403 missing_permission` otherwise, naming no version) and the request runs [the story move](#moving-a-story-post-v1storystory_idmove) rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (`title`, `author_ids`, `slug`, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and every `revision` unchanged, and the edited version's `revision` rises by exactly 1 for the whole request. To move a story with several readable versions, use [the story move](#moving-a-story-post-v1storystory_idmove). On a story with one version it moves the version when its language is enabled at the destination (`422 language_not_enabled`, with `X-Missing-Languages`, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (`409 slug_taken`).
- `lang`, `story_id`, `source_article_id` and `copy` in the body are ignored.
The `200` body is the full article object with the new `revision`.
## Publishing
`PATCH /v1/article/{id}/publish` on an offline version whose language is disabled on its site answers `422 language_disabled`. Publishing an already online version is idempotent (`200`, unchanged) even when its language is disabled; a version without language publishes as before. `unpublish` and `PATCH` stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.
## Creating a translation
Two entry points share one implementation: the same result, errors, headers and stored data.
### `POST /v1/article/{id}/translations`
Verified session or API key with `content.create`; the version `{id}` must be readable by the caller.
```json
{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
```
| Field | Rule |
| --- | --- |
| `lang` | required; normalized (`FR` is stored as `fr`) |
| `title` | required, 3 to 200 characters |
| `copy` | required boolean (`400` when absent) |
| `source_article_id` | optional, only with `copy: true` (`400` with `copy: false`); default is `{id}`; must be a readable version of the same story |
The new version joins the story of `{id}`, in the same site, as an offline draft created by the caller, with `revision` `1`. The slug is generated from the new title in (site, language) and gets the next free `-N` suffix when taken.
- `copy: true` copies `body`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_id`, `cover_image_alt`, `social_image_id` and the authors from the source, and never `slug`, `canonical_url` (empty), `noindex` (false) or `seo_dismissed` (empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows `"source": {"article_id": <source>, "revision": <content revision then>}`.
- `copy: false` starts empty: empty text, no images, `authors` is the creating user's byline, `"source": null`.
Answers `201` with the created article object. Order of checks: permission, path id, body, version readable, `copy` rules, language tag, `setup_required`, source, language configuration. A story deleted in the meantime answers `404 Article not found`. Both entry points leave the stored `lang` normalized.
### `POST /v1/article/` with `story_id`
```json
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
```
`story_id` selects the story (`404 Article not found` when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of `{id}`. `lang` is then required (`400`). `source_article_id` and `copy` are only valid with `story_id` (`400` otherwise).
### `POST /v1/article/` without `story_id`
Unchanged: it creates a new story with one offline version. `lang` is now optional: absent, the version gets the starting language of the site as before; present, it is normalized and must be an enabled language of the default site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled; `409 setup_required` before the site default is confirmed).
## Deleting
`DELETE /v1/article/{id}` removes one version (`204`, empty body) and checks `content.delete` per version (`403 missing_permission`). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
## Moving a story: `POST /v1/story/{story_id}/move`
```json
{"site": "atelier"}
```
Verified session or API key. The caller needs `content.update` on every version of the story, readable or not, and must read at least one (`404 Article not found` otherwise, also for an unknown story). `site` is the key of the destination (`400` when absent or empty, `404 site_not_found` when unknown). Answers `200`:
```json
{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}
```
`article_ids` lists the readable versions only, oldest first. Every version moves in one transaction: `site_id` changes, slugs, languages, publication, content and authors are unchanged, `revision` of each moved version rises by 1 and its content revision does not. Moving to the current site answers `200` and changes nothing.
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (`422 language_not_enabled`, `X-Missing-Languages` limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (`409 slug_conflict`, `X-Conflicting-Articles` limited to readable versions). A conflict that appears between the check and the write answers `409 slug_conflict` without the header.
## Mock-ready examples
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
| Call | Status | Headers | Body |
| --- | --- | --- | --- |
| `GET /v1/article/10` (see above) | 200 | | article object with `revision`, `capabilities`, `translations`, `source` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` | 201 | | article object, `lang` `"fr"`, `"is_online": false`, `"revision": 1`, `"source": {"article_id": 10, "revision": 4}` |
| `DELETE /v1/article/12` | 204 | | empty |
| `POST /v1/story/5/move` `{"site":"atelier"}` | 200 | | `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts"}` | 400 | | `The request cannot be processed due to a mismatch in the format of the body.` |
| `PATCH /v1/article/10` `{"title":"ab"}` | 400 | | `Article title must be between 3 and 200 characters.` |
| `POST /v1/article/10/translations` with a cookie of an anonymous client | 401 | | `You must be logged in to access this resource.` |
| `POST /v1/story/5/move` by a user holding `content.update` at `own` | 403 | `X-Missing-Permission: content.update` | `missing_permission` |
| `POST /v1/article/9999/translations` | 404 | | `Article not found` |
| `POST /v1/article/10/translations` with `"source_article_id":9999` | 404 | | `source_unavailable` |
| `POST /v1/story/5/move` `{"site":"nowhere"}` | 404 | | `site_not_found` |
| `PATCH /v1/article/10` `{"body":"x","if_revision":3}` after somebody saved | 409 | `X-Current-Revision: 4` | `revision_conflict` |
| `POST /v1/article/10/translations` `{"lang":"de","title":"Kosten","copy":true}` | 409 | `X-Existing-Article-Id: 11` | `language_exists` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` with fr disabled, unreadable or raced | 409 | none | `language_unavailable` |
| `POST /v1/article/30/translations` on an article without language | 409 | | `setup_required` |
| `PATCH /v1/article/10` `{"slug":"taken-en"}` | 409 | `X-Slug-Suggestion: taken-en-2` | `slug_taken` |
| `POST /v1/story/5/move` `{"site":"atelier"}` with an address taken at the destination | 409 | `X-Conflicting-Articles: 12` | `slug_conflict` |
| `PATCH /v1/article/10` `{"site_id":2}` on a story with several readable versions | 409 | | `story_move_required` |
| `POST /v1/article/10/translations` `{"lang":"pt","title":"Custos","copy":true}` with no `pt` row | 422 | | `language_not_enabled` |
| `POST /v1/story/5/move` `{"site":"atelier"}` with no `ar` at the destination | 422 | `X-Missing-Languages: ar` | `language_not_enabled` |
| `PATCH /v1/article/15/publish` with `es` disabled | 422 | | `language_disabled` |
| `POST /v1/article/10/translations` `{"lang":"xx","title":"Couts","copy":true}` | 422 | | `unsupported_language` |
| `POST /v1/article/10/translations` `{"lang":"english","title":"Couts","copy":true}` | 422 | | `invalid_language` |
| `POST /v1/article/10/translations` with `"source_article_id":20` of another story | 422 | | `source_other_story` |
## Activity
No new action code. The existing events carry `params.lang` (the language of the version, omitted for a version without language): `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete`. A translation creation records `article.create` on the new version with `params.lang`, `params.story_id` and `params.source_article_id` (only when copied). A story move records one `article.edit` per moved version, with a `site` change (site names before and after) and `params.lang`; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered `404` are not recorded.
+4 -2
View File
@@ -1,8 +1,10 @@
# Reading articles # Reading articles
Versions, translations, `revision`, `if_revision`, capabilities and the story move: [article-localization.md](article-localization.md).
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. 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`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and `null` answers `400` except for `cover_image_id` and `social_image_id`. 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 `title`, `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`), the SEO fields listed below and the optional `if_revision`, and nothing else. The request must contain at least one of them (`if_revision` alone is not an update), and `null` answers `400` except for `cover_image_id` and `social_image_id`. 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` ## Routes that honour `format`
@@ -93,7 +95,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
} }
``` ```
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only: `lang` and `story_id` sent in a `POST` or `PATCH` body are ignored. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed. `lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only on `PATCH`, where `lang` and `story_id` are ignored. `POST /v1/article/` takes `lang`, `story_id`, `source_article_id` and `copy` only as described in [article-localization.md](article-localization.md), which also documents `revision`, `capabilities`, `translations` and `source`, the additive keys of every article read. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish. `created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
+1 -1
View File
@@ -94,7 +94,7 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package. `internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockStartingLanguage` (which locks the site row and returns the confirmed default tag, or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `ArticleRepository.Create` only stores what it receives and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `SiteLanguageRepository.ConfirmDefault` (lock, read the default, guard, flag it confirmed, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits. The "default and confirmed" predicate lives in one repository helper shared by both. `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows. Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockStartingLanguage` (which locks the site row and returns the confirmed default tag, or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `ArticleRepository.Create` only stores what it receives and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `SiteLanguageRepository.ConfirmDefault` (lock, read the default, guard, flag it confirmed, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits. The "default and confirmed" predicate lives in one repository helper shared by both. `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`. `database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
+2 -2
View File
@@ -58,8 +58,8 @@ Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop.
## Writes ## Writes
- A title cannot be changed after creation. Create a new article instead. - A title can be changed with `PATCH /v1/article/{id}` (3 to 200 characters); the slug is not regenerated. Send `if_revision` to refuse overwriting a newer save (`409 revision_conflict`).
- `PATCH /v1/article/{id}` accepts `body`, `site_id`, `author_ids` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image. - `PATCH /v1/article/{id}` accepts `title`, `body`, `site_id`, `author_ids`, `if_revision` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image.
- Request bodies are HTML even though responses default to markdown. Send `<p>` and `<h2>`, not markdown syntax, or it is stored literally. - Request bodies are HTML even though responses default to markdown. Send `<p>` and `<h2>`, not markdown syntax, or it is stored literally.
- Ids in paths are unsigned integers. Anything else answers `400`. - Ids in paths are unsigned integers. Anything else answers `400`.
+1 -1
View File
@@ -10,7 +10,7 @@ export RENEWCMS_API_KEY=rcms_...
## 1. Create a draft ## 1. Create a draft
Titles are 3 to 200 characters and cannot be changed later. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`. Titles are 3 to 200 characters and can be edited later with `PATCH`. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`.
```sh ```sh
curl -X POST "{{BASE_URL}}/v1/article/" \ curl -X POST "{{BASE_URL}}/v1/article/" \
+183
View File
@@ -0,0 +1,183 @@
Feature: Edit title and slug, and protect overwrites with a revision
A title can be edited at any time (3 to 200 runes). Editing the title never changes the slug. The slug changes only when sent explicitly, before or after publication, with no redirect. Every article read carries a "revision"; a PATCH may send "if_revision" so a stale editor cannot overwrite silently.
Background:
Given error responses are plain text (a snake_case code or an existing apperr message)
And a "writer" holds content.update and content.read at scope all
And article 10 (site 1, lang en, story 5, online, title "Renovation costs", slug "renovation-costs", authors [Camille]) has revision 3 and content_revision 4
And article 12 (site 1, lang fr, story 5, offline, slug "couts") exists
Scenario Outline: Title boundaries on PATCH, counted in runes
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"title": <title>}
Then the response status is <status>
And <effect>
Examples:
| title | status | effect |
| 2 ASCII characters | 400 | the body is apperr.ArticleTitleErrorMessage, nothing changed |
| 3 ASCII characters | 200 | the title is stored |
| 200 ASCII characters | 200 | the title is stored |
| 201 ASCII characters | 400 | the title is unchanged |
| 200 repetitions of the 4-byte rune "😀" | 200 | the title is stored (200 runes, 800 bytes) |
| "" | 400 | the body is apperr.ArticleTitleErrorMessage, nothing changed |
| null | 400 | the body is apperr.BodyErrorMessage |
Scenario: Editing the title never changes the slug
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"title":"New title"}
Then the response status is 200
And the slug is still "renovation-costs"
And the article is still online
Scenario: Title and body in one request
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"title":"New title","body":"<p>x</p>"}
Then both are stored in one write
And revision rises by exactly 1
Scenario: The title is editable on a single-language site
Given site "archive" has a single language
When a writer sends PATCH on an archive article with {"title":"New title"}
Then the response status is 200
And the slug is unchanged
Scenario Outline: Explicit slug edits
Given a valid jwt cookie for a writer
When the client sends PATCH with <request>
Then the response status is <status>
And <effect>
Examples:
| request | status | effect |
| /v1/article/10 {"slug":"new-address"} (online) | 200 | still online, no redirect row, the old slug reads as not found publicly |
| /v1/article/12 {"slug":"new-address"} (offline) | 200 | the slug is stored |
| /v1/article/10 {"slug":"renovation-costs"} (same as current) | 200 | nothing changes, revision unchanged |
| /v1/article/10 {"slug":"Not A Slug"} | 400 | the body is apperr.InvalidSlugErrorMessage |
| /v1/article/10 {"slug":"couts"} (used by another language of the same story) | 200 | the slug is stored |
| /v1/article/10 {"slug":"taken-en"} (used by another en article of site 1) | 409 | the body is "slug_taken" with the header X-Slug-Suggestion |
| /v1/article/10 {"slug":""} | 200 | the slug is regenerated from the current title |
Scenario: The same slug in two languages is not a conflict
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"slug":"couts"}
Then the response status is 200
And article 10 (en) and article 12 (fr) both hold the slug "couts"
Scenario: Revision is exposed on every article read and write
Given a valid jwt cookie for a writer
When the client calls GET /v1/article/10, the list, POST, PATCH, publish and unpublish
Then each response carries "revision" as a positive integer
And every pre-existing key is unchanged in name, value and type
Scenario Outline: What raises revision and content_revision
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 changing only <field>
Then revision rises by 1
And content_revision <content_effect>
Examples:
| field | content_effect |
| title | rises by 1 |
| body | rises by 1 |
| excerpt | rises by 1 |
| slug | rises by 1 |
| cover_image_alt | rises by 1 |
| seo_title | rises by 1 |
| seo_description | rises by 1 |
| social_title | rises by 1 |
| social_description | rises by 1 |
| author_ids | is unchanged |
| cover_image_id | is unchanged |
| social_image_id | is unchanged |
| site_id (single-version story) | is unchanged |
| canonical_url | is unchanged |
| noindex | is unchanged |
| seo_dismissed | is unchanged |
Scenario: Several content fields in one PATCH raise each counter once
When a writer sends PATCH /v1/article/10 changing title, body and excerpt
Then revision rises by 1 and content_revision rises by 1
Scenario: A PATCH that changes nothing leaves both counters alone
When a writer sends PATCH /v1/article/10 with the current values
Then the response status is 200
And revision and content_revision are unchanged
Scenario: Publishing and unpublishing do not touch revisions
When article 12 is published and then unpublished
Then its revision and content_revision are unchanged
Scenario: if_revision matches
When a writer sends PATCH /v1/article/10 with {"body":"x","if_revision":3}
Then the response status is 200
And the response revision is 4
Scenario: if_revision is stale
Given another writer already saved article 10, so its revision is 4
When a writer sends PATCH /v1/article/10 with {"body":"x","if_revision":3}
Then the response status is 409 and the body is "revision_conflict"
And the header X-Current-Revision is 4
And nothing is stored
And no successful "article.edit" activity event is recorded
Scenario: A stale if_revision wins over a no-op PATCH
Given article 10 has revision 4
When a writer sends PATCH /v1/article/10 with the current values and "if_revision":3
Then the response status is 409 and the body is "revision_conflict"
Scenario: Overwrite uses the revision the conflict dialog showed
Given the conflict dialog showed revision 4
When a writer sends PATCH /v1/article/10 with "if_revision":4
Then the response status is 200 and revision is 5
When another writer saved in between
Then the response is 409 "revision_conflict" with the then-current X-Current-Revision
And nothing is overwritten
Scenario: Concurrent saves with the same if_revision
When two PATCH requests with the same if_revision run at the same time
Then exactly one answers 200 and the other 409
And the compare and the write are one atomic step
Scenario: Without if_revision the last write wins
When a writer sends PATCH /v1/article/10 with {"body":"x"}
Then the response status is 200
And revision still rises by 1
Scenario Outline: Invalid if_revision
When a writer sends PATCH /v1/article/10 with {"body":"x","if_revision":<value>}
Then the response status is 400 and the body is apperr.BodyErrorMessage
Examples:
| value |
| 0 |
| -1 |
| "3" |
| 1.5 |
| null |
Scenario: if_revision alone is not an update
When a writer sends PATCH /v1/article/10 with {"if_revision":3}
Then the response status is 400 and the body is apperr.BodyErrorMessage
Scenario: Other languages are untouched by an overwrite
When a writer overwrites article 10 with a matching if_revision
Then article 12 keeps its content, its revision and its publication
Scenario: Permissions are unchanged
Then a caller without content.update gets 403 "missing_permission"
And a caller who cannot read the article gets 404 "Article not found"
And an anonymous caller gets 401
And a stale if_revision never reveals current content to a caller lacking permission because permission is checked first
Scenario: Editing an online article without content.publish changes the live page right away
Given a user holds content.update but not content.publish
When the user sends PATCH /v1/article/10 with {"title":"New title"}
Then the response status is 200
And a public read shows the new title
Scenario: Title and slug edits are recorded
When a writer edits the title and the slug of article 10
Then one "article.edit" event is recorded
And its changes include the title and the slug
And its params hold lang "en"
+23
View File
@@ -0,0 +1,23 @@
# QA: Edit title and slug, revision protection (backend)
Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test)
1. UpdateArticle use case over fakes and a fake Transactor, table-driven over title boundaries, explicit slug edits and revision-raising fields. Assert article.ErrInvalidTitle, ErrInvalidSlug, ErrSlugTaken and the new revision-conflict sentinel with errors.Is. The revision compare-and-set and the write run in the same transaction. content_revision rises only for the listed fields.
2. HTTP via NewHandler, a chi router mirroring api/router.go, validator.InitValidator(), httptest and a jwt cookie. A PATCH with "title" present but empty must be 400 (guard against omitempty on a *string skipping validation). Assert body strings, X-Current-Revision, X-Slug-Suggestion, and "revision" in the read, list, POST, PATCH, publish and unpublish JSON, with old keys unchanged.
3. Two goroutines with the same if_revision: exactly one answers 200.
4. Regression: article_title_rules, article_publishing, SEO and pagination tests stay untouched, except the updated pair text.
## Automated, SQLite file (task test)
5. The migration adds edit_revision with default 1 for existing rows; a second Migrate changes nothing.
6. The compare-and-set is one UPDATE ... WHERE edit_revision = ? (or equivalent). With concurrent writers, one wins.
## Automated, real databases (task test:mysql, task test:postgres)
7. Repeat 5-6 on MySQL 8.4, MariaDB 11 and PostgreSQL. Record NOT RUN if the daemon is down, or use the podman shim (see memory).
## Build and lint
8. task test, task lint:go and CGO_ENABLED=0 go build -tags nofrontend ./... pass. No comments in the added Go files. task mutate scoped via MUTATE_FILES to the revision and title code.
9. docs/api/articles.md gains title, slug, revision, if_revision and X-Current-Revision.
## Manual
None.
@@ -136,9 +136,11 @@ Feature: Article language, story and per-site language storage
| fr-CA | GET /v1/article/ list item | | fr-CA | GET /v1/article/ list item |
| fr-CA | public article read of an online article | | fr-CA | public article read of an online article |
Scenario: Article write payloads cannot set lang or story_id Scenario: PATCH ignores lang and story_id
When POST or PATCH /v1/article carries "lang" or "story_id" When PATCH /v1/article/{id} carries "lang" or "story_id"
Then the fields are ignored and the stored values are unchanged Then the fields are ignored and the stored values are unchanged
And POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature
And a POST without them behaves as before
Scenario: Existing behaviour is unchanged Scenario: Existing behaviour is unchanged
Then the existing article, pagination, publishing and SEO tests pass without edits Then the existing article, pagination, publishing and SEO tests pass without edits
+1 -1
View File
@@ -5,7 +5,7 @@ Backend only. Go tests in test/, black-box. No design states. No new endpoints.
## Automated, no server (task test) ## Automated, no server (task test)
1. Language domain: table-driven over "Language tag normalization" and "Suggestion for a site's default language". Assert the sentinel errors with errors.Is. 1. Language domain: table-driven over "Language tag normalization" and "Suggestion for a site's default language". Assert the sentinel errors with errors.Is.
2. Use case: with fake repositories, run "Creating an article before the default is confirmed" and "after the default is confirmed". Assert that CreateArticle runs inside the Transactor, that the starting language is obtained through LockStartingLanguage of the site language repository (under the site-row lock) and passed to Create as Lang, and that the story is created by the article repository inside the same transaction. Existing create tests stay green. 2. Use case: with fake repositories, run "Creating an article before the default is confirmed" and "after the default is confirmed". Assert that CreateArticle runs inside the Transactor, that the starting language is obtained through LockStartingLanguage of the site language repository (under the site-row lock) and passed to Create as Lang, and that the story is created by the article repository inside the same transaction. Existing create tests stay green.
3. HTTP: NewHandler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring api/router.go, validator.InitValidator(), httptest. Cover "Article reads expose lang and story_id" for GET /v1/article/{id} with a jwt cookie signed by JWT_SECRET (the test secret), the list GET /v1/article/, and the public read of an online article. Assert that old keys are unchanged, that lang and story_id are the added keys, and that unknown "lang" or "story_id" in the request body is ignored. Errors stay plain text. 3. HTTP: NewHandler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring api/router.go, validator.InitValidator(), httptest. Cover "Article reads expose lang and story_id" for GET /v1/article/{id} with a jwt cookie signed by JWT_SECRET (the test secret), the list GET /v1/article/, and the public read of an online article. Assert that old keys are unchanged, that lang and story_id are the added keys, and that "lang" or "story_id" in a PATCH body is ignored. POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature, and a POST without them behaves as before. Errors stay plain text.
4. Regression: run the full existing article, pagination, publishing and SEO tests untouched. 4. Regression: run the full existing article, pagination, publishing and SEO tests untouched.
## Automated, SQLite file (task test) ## Automated, SQLite file (task test)
+29
View File
@@ -1,6 +1,8 @@
Feature: An authenticated user can publish and unpublish an article Feature: An authenticated user can publish and unpublish an article
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Extended by article_story_lifecycle: publishing a version in a disabled language answers 422 language_disabled.
As an authenticated RenewCMS user managing the CMS As an authenticated RenewCMS user managing the CMS
I want to flip an article's is_online flag from an HTTP endpoint I want to flip an article's is_online flag from an HTTP endpoint
So that a draft can be published to the public site and a published article can be So that a draft can be published to the public site and a published article can be
@@ -25,6 +27,7 @@ Feature: An authenticated user can publish and unpublish an article
And an id absent from the article repository answers 404 with body And an id absent from the article repository answers 404 with body
apperr.ArticleNotFoundErrorMessage, backed by errors.Is(err, article.ErrArticleNotFound), apperr.ArticleNotFoundErrorMessage, backed by errors.Is(err, article.ErrArticleNotFound),
the same as DeleteArticle already does per features/write_not_found_errors.feature the same as DeleteArticle already does per features/write_not_found_errors.feature
And publishing and unpublishing act on one article id, one language version; sibling versions are untouched
And GET /v1/article/ with no jwt cookie only returns online articles, per And GET /v1/article/ with no jwt cookie only returns online articles, per
features/public_article_listing.feature; this slice does not change that behavior, features/public_article_listing.feature; this slice does not change that behavior,
only feeds it only feeds it
@@ -93,6 +96,32 @@ Feature: An authenticated user can publish and unpublish an article
| publish | | publish |
| unpublish | | unpublish |
Scenario: Publishing a draft in a disabled language is refused
Given a valid jwt cookie for a user holding content.publish
And article 15 is offline in language "es", which is disabled on its site
When the client sends PATCH /v1/article/15/publish with that cookie
Then the response status is 422
And the response body is the text "language_disabled"
And article 15 stays offline
Scenario: An online version in a disabled language can still be unpublished, and publish is idempotent
Given a valid jwt cookie for a user holding content.publish
And article 16 is online in language "es", which is disabled on its site
When the client sends PATCH /v1/article/16/unpublish with that cookie
Then the response status is 200
And the response body is the article with id 16 and "is_online": false
When article 16 is online again and the client sends PATCH /v1/article/16/publish with that cookie
Then the response status is 200
And the response body is the article with id 16 and "is_online": true
Scenario: Publishing one language leaves the other versions alone
Given a valid jwt cookie for a user holding content.publish
And story 5 holds article 10 (en, online) and article 12 (fr, offline)
When the client sends PATCH /v1/article/12/publish with that cookie
Then the response status is 200
And article 12 is online
And the publication and updated_at of article 10 are unchanged
Scenario: Publishing an article makes it appear in the public online-only list Scenario: Publishing an article makes it appear in the public online-only list
Given a valid jwt cookie for a user with id 1 Given a valid jwt cookie for a user with id 1
And the article repository contains an offline article titled "Draft: Kitchen Remodel" with id 2 And the article repository contains an offline article titled "Draft: Kitchen Remodel" with id 2
+9
View File
@@ -50,6 +50,15 @@ Package: `test` (black-box, extending `article_test.go`).
the valid cookie (assert `200`), then call `GET /v1/article/` with no cookie and assert the the valid cookie (assert `200`), then call `GET /v1/article/` with no cookie and assert the
decoded JSON array does not contain an element with id `1`. decoded JSON array does not contain an element with id `1`.
17. Disabled language: add a fake language repo row for "es" with enabled false and seed offline
article 15 in lang "es". `PATCH /v1/article/15/publish` -> status `422`, body
`"language_disabled\n"`, article stays offline, no `UpdateIsOnline` call recorded on the fake.
18. Seed online article 16 in lang "es" (disabled). `PATCH /v1/article/16/unpublish` -> `200`,
`is_online == false`. Re-seed it online, `PATCH /v1/article/16/publish` -> `200`,
`is_online == true`.
19. Siblings: seed story 5 with article 10 (en, online) and article 12 (fr, offline).
`PATCH /v1/article/12/publish` -> `200`; article 10's `IsOnline` and `UpdatedAt` are unchanged.
## Manual verification (not exercised by the Go test) ## Manual verification (not exercised by the Go test)
- SPA-visible behaviour: confirm the back-office article editor exposes a publish/unpublish - SPA-visible behaviour: confirm the back-office article editor exposes a publish/unpublish
control that calls the new endpoints and updates the visible online/offline state, against the control that calls the new endpoints and updates the visible online/offline state, against the
+255
View File
@@ -0,0 +1,255 @@
Feature: Capabilities, translations, publish rules, delete and story move
An article read tells the editor what the caller may do with the version and which readable sibling versions exist. Publishing respects disabled languages. Deleting the last version removes its story. A story moves to another site as a whole, atomically, never split.
Background:
Given error responses are plain text, with extra data in headers
And site "journal" (id 1) has enabled languages en (default), fr, de, ar and disabled language es
And site "atelier" (id 2) has enabled languages en (default), fr, de
And story 5 in journal holds article 10 (en, online, created_by 1, slug renovation-costs), 12 (fr, offline, 1, couts), 11 (de, offline, 2, kosten) and 13 (ar, online, 3, takalif)
And "owner" holds every content permission at scope all
And "writer1" (user 1) holds content.create, content.read, content.update, content.publish and content.delete at scope own, and content.read at scope all for online articles only
And "reader" holds content.read at scope all only
Scenario: Capabilities of the owner
When the owner sends GET /v1/article/12
Then "capabilities" is ["read","edit","publish","delete","create_translation"]
Scenario Outline: Capabilities per caller and version
When <caller> sends GET /v1/article/<id>
Then "capabilities" is <capabilities>
Examples:
| caller | id | capabilities |
| writer1 | 12 | ["read","edit","publish","delete","create_translation"] |
| writer1 | 11 | ["read","create_translation"] |
| reader | 10 | ["read"] |
| a user with content.update and content.read all | 12 | ["read","edit"] |
| a user with content.publish and content.read all | 12 | ["read","publish"] |
| anonymous | 10 | ["read"] |
Scenario: Translations list readable versions only
When writer1 sends GET /v1/article/10
Then "translations" has entries for 10, 12 and 13 and none for 11
And each entry has id, lang, title, is_online, language_enabled and capabilities
And no count, placeholder or total names the hidden version
Scenario: Translations are ordered default language first, then by tag
When the owner sends GET /v1/article/12
Then the translations are ordered en, ar, de, fr
And the version being read is included
Scenario: Disabled languages are marked
Given language "fr" is disabled on journal
When the owner sends GET /v1/article/10
Then the fr entry has "language_enabled": false and is still listed
Scenario: A single-version article lists itself
Given article 20 is alone in story 8
When the owner sends GET /v1/article/20
Then "translations" has one entry, article 20 itself
Scenario: Anonymous reads expose nothing about siblings
When an anonymous client sends GET /v1/article/10
Then "translations" is []
And "capabilities" is ["read"]
And the public shape is otherwise unchanged
Scenario: The source on the read
Given article 14 was created from article 10 at content_revision 4
When the owner sends GET /v1/article/14
Then "source" is {"article_id":10,"revision":4}
When article 10 is deleted or unreadable to the reader
Then "source" is null
And a version created empty has "source": null
Scenario Outline: Publishing respects the language configuration
Given a valid jwt cookie for a user holding content.publish
When the client sends PATCH /v1/article/<id>/publish
Then the response status is <status>
And <effect>
Examples:
| id | situation | status | effect |
| 12 | fr enabled | 200 | "is_online": true |
| 15 | es disabled, offline version | 422 | the body is "language_disabled" |
| 16 | es disabled, already online | 200 | unchanged, "is_online": true |
| 30 | unassigned lang "" | 200 | "is_online": true |
Scenario: Unpublish and edit stay available in a disabled language
Given fr is disabled on journal and article 12 is online
When the owner sends PATCH /v1/article/12/unpublish
Then the response status is 200
When the owner sends PATCH /v1/article/12 with {"body":"x"}
Then the response status is 200
Scenario: Publishing one version leaves the others alone
When the owner publishes article 12
Then articles 10, 11 and 13 keep their publication and updated_at
Scenario: Re-enabling a language allows publishing again
Given es is re-enabled on journal
When the owner sends PATCH /v1/article/15/publish
Then the response status is 200
Scenario Outline: Existing publish errors are unchanged
When <request>
Then the response status is <status>
And the response body is <message>
Examples:
| request | status | message |
| the owner publishes article 9999 | 404 | Article not found |
| the owner publishes article "abc" | 400 | apperr.IdUint32ErrorMessage |
| an anonymous client publishes article 12 | 401 | Unauthorized |
| the reader publishes article 12 | 403 | missing_permission |
Scenario: Deleting one version keeps the story
When the owner sends DELETE /v1/article/12
Then the response status is 204
And articles 10, 11 and 13 and story 5 remain
And the translations no longer list fr
Scenario: A deleted language can be created again
Given article 12 (fr) was deleted
When the owner sends POST /v1/article/10/translations with lang "fr"
Then the response status is 201
And fr of journal still counts as in use while a deleted fr version exists, per features/site_languages.feature
Scenario: Deleting the last version removes the story
Given story 8 holds only article 20
When the owner sends DELETE /v1/article/20
Then the response status is 204 and story 8 is gone
And creating a translation from story 8 answers 404 "Article not found"
Scenario: Deleting the last of several versions
When the owner deletes 10, 11, 12 and 13 one after another
Then story 5 exists after each of the first three deletions
And story 5 is gone after the fourth
Scenario: Delete is checked per version
When writer1 sends DELETE /v1/article/11
Then the response status is 403 and the body is "missing_permission"
And article 11 remains
Scenario: A story with a hidden version
Given writer1 deletes the last version it can see while an unreadable version remains
Then the story remains
And nothing in the response mentions the other version
Scenario: Move a story
When the owner sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is 200
And the body is {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}
And all four articles have site_id 2
And their slugs, lang, publication, content and authors are unchanged
And each moved version's revision rises by 1 and content_revision is unchanged
Scenario: A move requires every language to be enabled at the destination
Given atelier has no "ar" language
When the owner sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is 422 and the body is "language_not_enabled"
And the header X-Missing-Languages is "ar", readable versions only
And nothing is moved
Scenario: A language disabled at the destination counts as missing
Given de is listed but disabled on atelier
When the owner moves story 5 to atelier
Then the response status is 422
And X-Missing-Languages includes "de"
Scenario: A language disabled at the source still needs the destination
Given fr is disabled on journal and enabled on atelier
When the owner moves story 5 to atelier
Then the move succeeds
Given fr is disabled on journal and missing on atelier
When the owner moves story 5 to atelier
Then the response status is 422 and the body is "language_not_enabled"
And X-Missing-Languages is "fr"
Scenario: Address conflict at the destination
Given atelier has an fr article with slug "couts" and an ar article with slug "takalif"
And the caller can read 10, 11 and 12 but not 13
When the caller moves story 5 to atelier
Then the response status is 409 and the body is "slug_conflict"
And the header X-Conflicting-Articles is 12
And article 13 is neither named nor counted
And nothing is moved
Scenario: Every conflict of the story is checked
Given two versions of the story conflict at the destination
When a caller who can read both moves the story
Then no version moves
And both readable conflicting versions are named in X-Conflicting-Articles
Scenario Outline: A move needs update permission on every version, readable or not
When <caller> sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is <status>
And the response body is <message>
And no version is moved
Examples:
| caller | status | message |
| an anonymous client | 401 | Unauthorized |
| writer1 | 403 | missing_permission |
| a user without content.update | 403 | missing_permission |
| a user who can read no version | 404 | Article not found |
Scenario: Hidden versions never leak through the move
Given the caller cannot read some versions of story 5
Then a 403 body and headers, a 422 X-Missing-Languages and a 409 X-Conflicting-Articles name only readable versions
And a 200 article_ids lists only readable versions
Scenario Outline: Move validation
When the owner sends <request>
Then the response status is <status>
And the response body is <message>
Examples:
| request | status | message |
| POST /v1/story/5/move with {"site":"nowhere"} | 404 | site_not_found |
| POST /v1/story/5/move with {} | 400 | apperr.BodyErrorMessage |
| POST /v1/story/5/move with malformed JSON | 400 | apperr.BodyErrorMessage |
| POST /v1/story/9999/move with {"site":"atelier"} | 404 | Article not found |
| POST /v1/story/abc/move with {"site":"atelier"} | 400 | apperr.IdUint32ErrorMessage |
Scenario: Moving to the current site
When the owner sends POST /v1/story/5/move with {"site":"journal"}
Then the response status is 200
And nothing changes
And no activity event is recorded
Scenario: The move is atomic and revalidated inside the transaction
When a version is edited, a language is disabled at the destination, or an address is taken between the pre-check and the write
Then the move is refused with the matching error and no version moves
When a write fails on the third version
Then the first two are rolled back
Scenario: PATCH site_id cannot split a story
When the owner sends PATCH /v1/article/10 with {"site_id":2}
Then the response status is 409 and the body is "story_move_required"
And nothing is changed
Scenario: PATCH site_id counts readable versions only
Given a story holds three versions and a caller reads only one of them
When that caller sends PATCH site_id on the readable version
Then content.update is required on every version of the story, readable or not, and the whole story moves under the story move rules
And every version, hidden ones included, ends up on the destination site in one transaction
And a language, address or slug refusal rolls everything back and names readable versions only
And a caller lacking update on any version gets 403 "missing_permission" with nothing naming a hidden version
And a caller who reads two versions gets 409 "story_move_required"
Scenario: PATCH site_id on a single-version story applies the move rules
Given story 8 holds only article 20 in lang "fr"
When the owner sends PATCH /v1/article/20 with {"site_id":2}
Then the article moves when fr is enabled at atelier
And otherwise the response is 422 "language_not_enabled"
And an unassigned article (lang "") moves without a language check
Scenario: Activity events carry the language
Then publish, unpublish, edit and delete events hold params lang, omitted for an unassigned article
And a move records one "article.edit" event per moved version with the site change and lang
And a refused move records one failed event with a plain reason naming no hidden version
Scenario: Persistence portability
Given the story delete rule, the (story_id, lang) re-creation after a delete and the atomic move with a unique (site_id, lang, slug) conflict
Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL
+25
View File
@@ -0,0 +1,25 @@
# QA: Capabilities, translations, publish rules, delete and story move (backend)
Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test)
1. Use cases over fakes (article, site, language, localization, role grants) and a fake Transactor: the capabilities and translations mapping, the publish rule, deleting the last version and MoveStory. Table-driven over every Examples table; assert the sentinels with errors.Is. MoveStory runs the permission check, the language check, the slug check and all writes inside one Transactor.Run, and a failing write rolls back all fake rows.
2. HTTP via the article NewHandler and the new story handler, a chi router mirroring api/router.go (/article and /story mounts, authtoken group), validator.InitValidator(), httptest and jwt cookies for owner, writer1, reader and none. Assert exact bodies and the headers X-Existing-Article-Id (absent where not allowed), X-Current-Revision, X-Conflicting-Articles and X-Missing-Languages. Hidden versions appear in no body, header or count.
3. Anonymous reads: the key set is the slice 1 set plus revision, capabilities, translations and source.
4. Activity: a fake recorder receives params.lang on publish, unpublish, edit, delete and per-version move events.
5. Regression: the article_publishing and article_title_rules tests pass with the updated pair text.
## Automated, SQLite file (task test)
6. Deleting the last version removes the story row, deleting one keeps it. Re-creating fr after deleting fr works. The site_languages in-use count still includes a deleted fr version (slice 2 TestLanguageInUseCountsSoftDeletedVersions stays green).
7. Move: all site_id values change together. A unique (site_id, lang, slug) violation at the destination rolls back every row. An injected write failure on the third row leaves all rows unchanged.
8. PATCH site_id counts readable versions only: with one readable version in a multi-version story it runs the full story move (every version, hidden ones too, moves in one transaction; language and slug refusals roll back and name readable versions only), which needs content.update on every version (403 missing_permission otherwise); with two readable versions it is 409 story_move_required.
## Automated, real databases (task test:mysql, task test:postgres)
8. Repeat 6-7 on MySQL 8.4, MariaDB 11 and PostgreSQL. Record NOT RUN if the daemon is down, or use the podman shim (see memory).
## Build and lint
9. task test, task lint:go and CGO_ENABLED=0 go build -tags nofrontend ./... pass. No comments in the added Go files. The architect checks api/router.go and api/dependecyInjection.go.
10. task mutate scoped via MUTATE_FILES to the new use cases.
## Manual
None.
+30 -3
View File
@@ -1,4 +1,5 @@
Feature: The article title rule is the same in the request and the domain Feature: The article title rule is the same in the request and the domain
Superseded in part by article_edit_revision: titles are editable.
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS As the maintainer of RenewCMS
@@ -26,9 +27,7 @@ Feature: The article title rule is the same in the request and the domain
500 with body "Failed to create article" 500 with body "Failed to create article"
And POST /v1/article/ sits behind the jwtauth Verifier and Authenticator group and And POST /v1/article/ sits behind the jwtauth Verifier and Authenticator group and
requires a valid jwt cookie requires a valid jwt cookie
And UpdateArticleUseCase exposes only UpdateBody, AddImage and UpdateIsOnline; no And PATCH /v1/article/{id} accepts `title` with the same 3-200 rune rule, rejected as 400 with apperr.ArticleTitleErrorMessage (see features/article_edit_revision.feature)
route or use case updates an article's title after creation, so this feature only
concerns article creation
Scenario Outline: CreateArticle now measures title length in runes, not bytes Scenario Outline: CreateArticle now measures title length in runes, not bytes
Given an empty article repository fake Given an empty article repository fake
@@ -76,6 +75,34 @@ Feature: The article title rule is the same in the request and the domain
And the response body is the text apperr.ArticleTitleErrorMessage And the response body is the text apperr.ArticleTitleErrorMessage
And the response body is not "Failed to create article" And the response body is not "Failed to create article"
Scenario Outline: PATCH /v1/article/{id} enforces the same 3-200 rune boundary
Given a valid jwt cookie for a user holding content.update
And the article repository contains an article with id 1
When the client sends PATCH /v1/article/1 with that cookie and a title of <length> ASCII characters
Then the response status is <status>
And <storage_effect>
Examples:
| length | status | storage_effect |
| 2 | 400 | the title is unchanged |
| 3 | 200 | the title is stored |
| 200 | 200 | the title is stored |
| 201 | 400 | the title is unchanged |
Scenario: A domain title rejection on update is a 400, not a 500
Given a valid jwt cookie for a user holding content.update
And the article repository fake is configured so its next Update call returns article.ErrInvalidTitle
When the client sends PATCH /v1/article/1 with that cookie and title "Valid Title"
Then the response status is 400
And the response body is the text apperr.ArticleTitleErrorMessage
Scenario: Editing the title does not change the slug
Given a valid jwt cookie for a user holding content.update
And the article with id 1 has slug "backyard-renovation-update"
When the client sends PATCH /v1/article/1 with that cookie and title "A brand new title"
Then the response status is 200
And the slug is still "backyard-renovation-update"
Scenario: A non-title repository failure during creation still answers 500 Scenario: A non-title repository failure during creation still answers 500
Given a valid jwt cookie for a user with id 1 Given a valid jwt cookie for a user with id 1
And the article repository fake is configured to fail on its next Create call with And the article repository fake is configured to fail on its next Create call with
+9
View File
@@ -73,6 +73,15 @@ Package: `test` (black-box, alongside `article_test.go` / `use_case_baseline_tes
article\n"` -- regression proof a non-title failure still answers 500. article\n"` -- regression proof a non-title failure still answers 500.
14. `POST /v1/article/` with no jwt cookie -> status `401` (regression: the route is 14. `POST /v1/article/` with no jwt cookie -> status `401` (regression: the route is
still gated). still gated).
15. `PATCH /v1/article/1` with a cookie of a user holding content.update and a title of
2 / 3 / 200 / 201 ASCII characters -> `400` unchanged / `200` stored / `200` stored /
`400` unchanged. Body `apperr.ArticleTitleErrorMessage + "\n"` on the 400s.
16. `PATCH /v1/article/1` with `"title": ""` (present but empty) -> `400`, title unchanged
(guard against `omitempty` on a `*string` skipping validation).
17. Configure the fake repo so its next `Update` returns `article.ErrInvalidTitle`.
`PATCH /v1/article/1` with title "Valid Title" -> `400`, body
`apperr.ArticleTitleErrorMessage + "\n"`.
18. `PATCH /v1/article/1` with only a new title -> `200`, slug unchanged.
## Manual verification (not exercised by the Go test) ## Manual verification (not exercised by the Go test)
- None. This slice has no SPA-visible behaviour and no SMTP involvement. - None. This slice has no SPA-visible behaviour and no SMTP involvement.
+189
View File
@@ -0,0 +1,189 @@
Feature: Create a translation of an article version
A user who can create articles adds a version of an existing article in another language. The version joins the story of the version it is created from, in the same site, as an offline draft. Two entry points share one implementation: POST /v1/article/{id}/translations and POST /v1/article/ with story_id. Hidden versions are never named, counted or hinted at.
Background:
Given the language rules of features/article_localization_storage.feature apply
And error responses are plain text whose body is the snake_case code, with extra data in response headers
And a "writer" is a verified user with content.create, content.update and content.read at scope all
And a "restricted writer" holds content.create and content.read at scope own
And site "journal" (id 1) has enabled languages en (default), fr, de, ar and disabled language es
And story 5 in "journal" holds article 10 (lang en, online, created by user 1, title "Renovation costs") and article 11 (lang de, offline, created by user 2)
And article 10 has body, excerpt, seo_title, seo_description, social_title, social_description, cover_image_id 3, cover_image_alt, social_image_id 4, canonical_url, noindex true, authors [Camille] and content_revision 4
Scenario: Copy from the source version
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts de renovation","copy":true}
Then the response status is 201 and the body is the created article JSON
And it has a new id, lang "fr", story_id 5, site_id 1, is_online false and the given title
And body, excerpt, seo_title, seo_description, social_title, social_description, cover_image_id, cover_image_alt, social_image_id and authors equal article 10's
And the slug is generated from the new title, not copied
And canonical_url is empty, noindex is false and seo_dismissed is empty
And created_by is the caller and revision is 1
And the source is article 10 with source_revision 4, captured in the same transaction as the copy
And articles 10 and 11 are unchanged
And no file or image row is duplicated
Scenario: Copy from a chosen source
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Kosten","copy":true,"source_article_id":11}
Then the response status is 201
And the content is copied from article 11
And the source is article 11 with its content_revision at that moment
Scenario: Start empty
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts de renovation","copy":false}
Then the response status is 201
And body, excerpt, SEO and social text are empty
And there is no cover image and no social image
And authors is the creating user's byline
And there is no source and GET shows "source": null
Scenario Outline: Request validation
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with <body>
Then the response status is <status>
And the response body is <message>
And nothing is stored
Examples:
| body | status | message |
| {"title":"Couts","copy":true} | 400 | the validate tag error text |
| {"lang":"fr","copy":true} | 400 | the validate tag error text |
| {"lang":"fr","title":"ab","copy":true} | 400 | the validate tag error text |
| {"lang":"fr","title":"<201 characters>","copy":true} | 400 | the validate tag error text |
| {"lang":"fr","title":"Couts"} | 400 | apperr.BodyErrorMessage |
| {"lang":"fr","title":"Couts","copy":false,"source_article_id":10} | 400 | apperr.BodyErrorMessage |
| {"lang":"fr", | 400 | apperr.BodyErrorMessage |
| {"lang":"english","title":"Couts","copy":true} | 422 | invalid_language |
| {"lang":"xx","title":"Couts","copy":true} | 422 | unsupported_language |
| {"lang":"ja-JP","title":"Couts","copy":true} | 422 | unsupported_language |
Scenario: The language tag is normalized
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with {"lang":"FR","title":"Couts","copy":true}
Then the response status is 201
And the stored lang is "fr"
Scenario: Language not configured on the site
Given site "journal" has no language row for "pt"
When a writer sends POST /v1/article/10/translations with {"lang":"pt","title":"Custos","copy":true}
Then the response status is 422 and the body is "language_not_enabled"
And nothing is stored
Scenario Outline: One neutral answer when the language cannot be used
Given a valid jwt cookie for the caller
And <situation>
When the caller sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts","copy":true}
Then the response status is 409 and the body is "language_unavailable"
And there is no X-Existing-Article-Id header
And the body and the header set are identical across the three situations
And nothing is stored
Examples:
| situation |
| an fr version exists but is unreadable to the restricted writer (draft of another user) |
| fr is disabled on the site |
| a concurrent request created fr first (unique violation on (story_id, lang)) |
Scenario: The language already exists and is readable
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with {"lang":"de","title":"Kosten","copy":true}
Then the response status is 409 and the body is "language_exists"
And the header X-Existing-Article-Id is 11
And nothing is stored
When the client sends the same request with "lang":"en"
Then the response status is 409 and the body is "language_exists"
And the header X-Existing-Article-Id is 10
Scenario: Two concurrent requests for the same language
Given a valid jwt cookie for a writer
When two requests for lang "fr" on story 5 run at the same time
Then exactly one answers 201
And the other answers 409 "language_unavailable" and stores nothing
And story 5 holds exactly one fr version
Scenario Outline: The path version and the source must be readable
When the caller sends <request>
Then the response status is <status>
And the response body is <message>
And nothing is stored
Examples:
| request | status | message |
| anonymous POST /v1/article/10/translations | 401 | Unauthorized |
| reader without content.create POST /v1/article/10/translations | 403 | missing_permission |
| writer POST /v1/article/9999/translations | 404 | Article not found |
| restricted writer POST /v1/article/11/translations | 404 | Article not found |
| writer POST /v1/article/abc/translations | 400 | apperr.IdUint32ErrorMessage |
| writer POST /v1/article/10/translations with source_article_id 9999 | 404 | source_unavailable |
| restricted writer POST /v1/article/10/translations with source_article_id 11 | 404 | source_unavailable |
| writer POST /v1/article/10/translations with source_article_id 20 (other story) | 422 | source_other_story |
Scenario: Access to the source is rechecked at submit
Given a writer opened the translation dialog with source article 11
And article 11 was deleted after the dialog opened
When the writer submits POST /v1/article/10/translations with source_article_id 11
Then the response status is 404 and the body is "source_unavailable", the same as for an unreadable source
And nothing is stored
Scenario: Unassigned articles cannot be translated yet
Given article 30 has lang ""
When a writer sends POST /v1/article/30/translations with {"lang":"fr","title":"Couts","copy":true}
Then the response status is 409 and the body is "setup_required"
And nothing is stored
Scenario: The slug of the new version is free within site and language
Given site "journal" already has an fr article with slug "couts-de-renovation"
When a writer creates an fr translation titled "Couts de renovation"
Then the new slug is the next free suffixed slug in (site 1, fr)
When a writer creates a translation with the same title in a language with no clash
Then the slug is the plain slug
Scenario: Entry point 2 behaves exactly like entry point 1
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/ with {"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true}
Then the response status is 201
And the result, errors, headers and stored data are exactly those of POST /v1/article/10/translations with the same lang, title and source
And the same use case runs for both
Scenario Outline: POST /v1/article/ is unchanged when it is not a translation
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/ with <body>
Then the response status is <status>
And <effect>
Examples:
| body | status | effect |
| {"title":"Plain","body":"x"} | 201 | a new story is created, lang is the site's starting language as before |
| {"title":"Neuf","lang":"fr"} | 201 | a new story is created in fr |
| {"title":"Neuf","lang":"pt"} | 422 | the body is "language_not_enabled" |
| {"title":"Neuf","lang":"es"} | 409 | the body is "language_unavailable" (disabled) |
| {"title":"Neuf","story_id":5} | 400 | the body is apperr.BodyErrorMessage |
| {"title":"Neuf","lang":"fr","story_id":999} | 404 | the body is "Article not found" |
| {"title":"Neuf","copy":true} | 400 | the body is apperr.BodyErrorMessage |
Scenario: PATCH still ignores lang, story_id and source fields
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"body":"x","lang":"fr","story_id":9}
Then the response status is 200
And lang and story_id are unchanged
Scenario: Activity records the creation
Given a valid jwt cookie for a writer
When a translation is created
Then one "article.create" event (articles category) targets the new article
And its params hold lang "fr", story_id 5 and source_article_id 10
When a creation is refused
Then one failed event is recorded with a plain reason
And nothing in it names a hidden version
Scenario: A new version does not appear publicly
Given a translation was created
When a client sends GET /v1/article/{new id} without a jwt cookie
Then the response status is 404 and the body is "Article not found"
And the public list is unchanged
Scenario: Persistence portability
Given the unique (story_id, lang) violation, the free-slug retry and the concurrent create
Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL
+26
View File
@@ -0,0 +1,26 @@
# QA: Create a translation (backend)
Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test)
1. CreateTranslation use case over in-memory fakes (article, site, language, author and image repositories, a fake Transactor; unique type names). Table-driven over "Copy from the source version", "Start empty", "Request validation" and "One neutral answer". Assert the sentinels with errors.Is (language exists, language unavailable, language not enabled, source unavailable, source other story, setup required). The read check, the source recheck, the language check, the free-slug search, the insert and the source_revision capture run inside one Transactor.Run, and the copy and source_revision come from the same read.
2. Both endpoints reach the same use case: a spy asserts one call per request with identical arguments.
3. HTTP: NewHandler over use cases, a fresh chi router mirroring api/router.go (POST /{id}/translations and POST / inside the authtoken group, requireArticle(content.create)), validator.InitValidator(), httptest and a jwt cookie. Cover every Examples row: status, exact plain-text body, presence or absence of X-Existing-Article-Id. The three neutral situations are byte-identical in body and header set.
4. Race: two goroutines request the same language, one wins, the other gets language_unavailable.
5. Activity: a fake audit recorder receives article.create with params lang, story_id and source_article_id. Failure events contain no hidden-version data.
6. Regression: the existing POST /v1/article tests stay untouched, except the amended slice 1 scenario.
## Automated, SQLite file (task test)
7. Repository: the unique (story_id, lang) violation maps to the language-unavailable sentinel; the slug retry works within (site, lang); source_article_id and source_revision round-trip; the migration adds them without changing existing rows; a second Migrate changes nothing.
8. Concurrency: N goroutines create fr for one story, exactly one succeeds, busy retries are bounded.
## Automated, real databases (task test:mysql, task test:postgres)
9. Repeat 7-8 on MySQL 8.4, MariaDB 11 and PostgreSQL. Record NOT RUN if the daemon is down, or use the podman shim (see memory).
## Build and lint
10. task test, task lint:go and CGO_ENABLED=0 go build -tags nofrontend ./... pass. No comments in the added Go files.
11. docs/api documents the endpoint, bodies, codes and headers.
12. task mutate scoped with MUTATE_FILES to the new use case.
## Manual
None.
+3
View File
@@ -137,6 +137,9 @@ type Article struct {
Lang string `json:"lang"` Lang string `json:"lang"`
StoryID uint32 `json:"story_id"` StoryID uint32 `json:"story_id"`
ContentRevision uint32 `json:"-"` ContentRevision uint32 `json:"-"`
EditRevision uint32 `json:"-"`
SourceArticleID *uint32 `json:"-"`
SourceRevision uint32 `json:"-"`
} }
func (a Article) UsesImage(imageID uint32) bool { func (a Article) UsesImage(imageID uint32) bool {
@@ -0,0 +1,161 @@
package article
import (
"RenewCMS/internal/domain/author"
"errors"
"slices"
)
var (
ErrRevisionConflict = errors.New("the article changed since it was opened")
ErrLanguageTaken = errors.New("the story already holds a version in that language")
ErrLanguageUnavailable = errors.New("the language cannot be used for this story")
ErrLanguageExists = errors.New("a readable version in that language already exists")
ErrLanguageNotEnabled = errors.New("the language is not enabled on the site")
ErrLanguageDisabled = errors.New("the language is disabled on the site")
ErrSourceUnavailable = errors.New("the source version cannot be used")
ErrSourceOtherStory = errors.New("the source version belongs to another story")
ErrInvalidTranslation = errors.New("the translation request is not valid")
ErrStoryMoveRequired = errors.New("the story must be moved as a whole")
ErrSlugConflict = errors.New("an address already exists on the destination site")
)
const (
CapabilityRead = "read"
CapabilityEdit = "edit"
CapabilityPublish = "publish"
CapabilityDelete = "delete"
CapabilityCreateTranslation = "create_translation"
)
type RevisionConflictError struct {
Current uint32
}
func (e *RevisionConflictError) Error() string {
return ErrRevisionConflict.Error()
}
func (e *RevisionConflictError) Is(target error) bool {
return target == ErrRevisionConflict
}
type LanguageExistsError struct {
ArticleID uint32
}
func (e *LanguageExistsError) Error() string {
return ErrLanguageExists.Error()
}
func (e *LanguageExistsError) Is(target error) bool {
return target == ErrLanguageExists
}
type LanguageNotEnabledError struct {
Missing []string
}
func (e *LanguageNotEnabledError) Error() string {
return ErrLanguageNotEnabled.Error()
}
func (e *LanguageNotEnabledError) Is(target error) bool {
return target == ErrLanguageNotEnabled
}
type SlugConflictError struct {
ArticleIDs []uint32
}
func (e *SlugConflictError) Error() string {
return ErrSlugConflict.Error()
}
func (e *SlugConflictError) Is(target error) bool {
return target == ErrSlugConflict
}
type Translation struct {
ID uint32 `json:"id"`
Lang string `json:"lang"`
Title string `json:"title"`
IsOnline bool `json:"is_online"`
LanguageEnabled bool `json:"language_enabled"`
Capabilities []string `json:"capabilities"`
}
type Source struct {
ArticleID uint32 `json:"article_id"`
Revision uint32 `json:"revision"`
}
type Impact struct {
Edited bool
Content bool
}
func ImpactOf(before Article, after Article, authorsChanged bool) Impact {
content := before.Title != after.Title ||
before.Body != after.Body ||
before.Excerpt != after.Excerpt ||
before.Slug != after.Slug ||
before.CoverImageAlt != after.CoverImageAlt ||
before.SEOTitle != after.SEOTitle ||
before.SEODescription != after.SEODescription ||
before.SocialTitle != after.SocialTitle ||
before.SocialDescription != after.SocialDescription
edited := content ||
authorsChanged ||
before.SiteID != after.SiteID ||
before.CanonicalURL != after.CanonicalURL ||
before.Noindex != after.Noindex ||
!sameImageID(before.CoverImageID, after.CoverImageID) ||
!sameImageID(before.SocialImageID, after.SocialImageID) ||
!slices.Equal(before.SEODismissed, after.SEODismissed)
return Impact{Edited: edited, Content: content}
}
const InitialRevision uint32 = 1
func (a Article) Opened() Article {
a.EditRevision = InitialRevision
a.ContentRevision = InitialRevision
return a
}
func (a Article) Revised(impact Impact) Article {
if impact.Edited {
a.EditRevision++
}
if impact.Content {
a.ContentRevision++
}
return a
}
func sameImageID(left *uint32, right *uint32) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func (a Article) CopiedInto(draft Article) Article {
draft.Body = a.Body
draft.Excerpt = a.Excerpt
draft.SEOTitle = a.SEOTitle
draft.SEODescription = a.SEODescription
draft.SocialTitle = a.SocialTitle
draft.SocialDescription = a.SocialDescription
draft.CoverImageID = a.CoverImageID
draft.CoverImageAlt = a.CoverImageAlt
draft.SocialImageID = a.SocialImageID
draft.Authors = slices.Clone(a.Authors)
if draft.Authors == nil {
draft.Authors = []author.Byline{}
}
draft.SourceArticleID = &a.ID
draft.SourceRevision = a.ContentRevision
return draft
}
@@ -47,6 +47,8 @@ type Nullable[T any] struct {
} }
type Update struct { type Update struct {
Title *string
IfRevision *uint32
Body *string Body *string
SiteID *uint32 SiteID *uint32
AuthorIDs *[]uint32 AuthorIDs *[]uint32
@@ -107,4 +109,6 @@ type Repository interface {
RestoreImage(references ImageReferences, imageID uint32) error RestoreImage(references ImageReferences, imageID uint32) error
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
AssignUnassignedLanguage(siteID uint32, lang string) (int, error) AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
ListByStory(storyID uint32) ([]Article, error)
MoveToSite(ids []uint32, siteID uint32) error
} }
+3
View File
@@ -214,6 +214,9 @@ const (
FieldLanguages = "languages" FieldLanguages = "languages"
FieldDefaultLanguage = "default_language" FieldDefaultLanguage = "default_language"
ParamAssigned = "assigned" ParamAssigned = "assigned"
ParamLang = "lang"
ParamStoryID = "story_id"
ParamSourceArticleID = "source_article_id"
maxNameLength = 255 maxNameLength = 255
maxActionLength = 64 maxActionLength = 64
maxFailureReasonSize = 255 maxFailureReasonSize = 255
@@ -93,3 +93,44 @@ func Plan(current []Row, counts map[string]VersionCount, change Change) ([]Row,
} }
return next, nil return next, nil
} }
func EnabledTag(rows []Row, tag string) bool {
return slices.ContainsFunc(rows, func(row Row) bool { return row.Tag == tag && row.Enabled })
}
func HasRow(rows []Row, tag string) bool {
return slices.ContainsFunc(rows, func(row Row) bool { return row.Tag == tag })
}
func MissingTags(rows []Row, tags []string) []string {
missing := []string{}
for _, tag := range tags {
if tag != language.Unassigned && !EnabledTag(rows, tag) && !slices.Contains(missing, tag) {
missing = append(missing, tag)
}
}
slices.Sort(missing)
return missing
}
func DefaultTag(rows []Row) string {
for _, row := range rows {
if row.IsDefault {
return row.Tag
}
}
return language.Unassigned
}
func CompareTags(defaultTag string, left string, right string) int {
if left == right {
return 0
}
if left == defaultTag {
return -1
}
if right == defaultTag {
return 1
}
return strings.Compare(left, right)
}
@@ -2,6 +2,7 @@ package database
import ( import (
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"fmt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -9,7 +10,9 @@ import (
const ( const (
articleStoriesMarker = "article_stories" articleStoriesMarker = "article_stories"
articleStoryLanguageIndex = "idx_articles_story_lang" articleStoryLanguageIndex = "idx_articles_story_lang"
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang)" articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang, retired_id)"
articleStoryRetiredMarker = "article_story_language_retired"
articleRetiredIDsMarker = "article_retired_ids"
) )
func BackfillArticleStories(db *gorm.DB) error { func BackfillArticleStories(db *gorm.DB) error {
@@ -35,9 +38,64 @@ func BackfillArticleStories(db *gorm.DB) error {
}) })
} }
func BackfillRetiredArticles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
applied, err := markerApplied(tx, articleRetiredIDsMarker)
if err != nil || applied {
return err
}
if err := tx.Unscoped().Model(&entity.Article{}).Where("deleted_at IS NOT NULL AND retired_id = ?", 0).UpdateColumn("retired_id", gorm.Expr("id")).Error; err != nil {
return err
}
return applyMarker(tx, articleRetiredIDsMarker)
})
}
type duplicateStoryLanguage struct {
StoryID uint32
Lang string
RetiredID uint32
Versions int
}
func checkStoryLanguagesUnique(db *gorm.DB) error {
var duplicates []duplicateStoryLanguage
err := db.Unscoped().Model(&entity.Article{}).
Select("story_id, lang, retired_id, COUNT(*) AS versions").
Group("story_id, lang, retired_id").
Having("COUNT(*) > 1").
Order("story_id ASC, lang ASC").
Limit(1).
Scan(&duplicates).Error
if err != nil || len(duplicates) == 0 {
return err
}
first := duplicates[0]
return fmt.Errorf("cannot create the unique index %s: story %d holds %d versions in language %q; merge or delete the extra versions, then start again", articleStoryLanguageIndex, first.StoryID, first.Versions, first.Lang)
}
func ensureArticleStoryLanguageIndex(db *gorm.DB) error { func ensureArticleStoryLanguageIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex) { replaced, err := markerApplied(db, articleStoryRetiredMarker)
if err != nil {
return err
}
indexed := db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex)
if replaced && indexed {
return nil return nil
} }
return db.Exec(articleStoryLanguageIndexSQL).Error if indexed {
if err := db.Migrator().DropIndex(&entity.Article{}, articleStoryLanguageIndex); err != nil {
return err
}
}
if err := checkStoryLanguagesUnique(db); err != nil {
return err
}
if err := db.Exec(articleStoryLanguageIndexSQL).Error; err != nil {
return err
}
if replaced {
return nil
}
return applyMarker(db, articleStoryRetiredMarker)
} }
@@ -41,6 +41,9 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := SeedSiteLanguageSuggestions(db); err != nil { if err := SeedSiteLanguageSuggestions(db); err != nil {
return err return err
} }
if err := BackfillRetiredArticles(db); err != nil {
return err
}
if err := ensureArticleStoryLanguageIndex(db); err != nil { if err := ensureArticleStoryLanguageIndex(db); err != nil {
return err return err
} }
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"slices"
"strings" "strings"
"time" "time"
@@ -25,6 +26,8 @@ const (
redactedPassword = "***" redactedPassword = "***"
mysqlLockNowait = 3572 mysqlLockNowait = 3572
mysqlLockWaitTimeout = 1205 mysqlLockWaitTimeout = 1205
mysqlDeadlock = 1213
mysqlSnapshotChanged = 1020
) )
type mysqlDriver struct{} type mysqlDriver struct{}
@@ -88,7 +91,9 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
return db return db
} }
var mysqlContentionCodes = []uint16{mysqlLockNowait, mysqlLockWaitTimeout, mysqlDeadlock, mysqlSnapshotChanged}
func (mysqlDriver) IsLockContention(err error) bool { func (mysqlDriver) IsLockContention(err error) bool {
var failure *mysqldriver.MySQLError var failure *mysqldriver.MySQLError
return errors.As(err, &failure) && (failure.Number == mysqlLockNowait || failure.Number == mysqlLockWaitTimeout) return errors.As(err, &failure) && slices.Contains(mysqlContentionCodes, failure.Number)
} }
@@ -7,6 +7,7 @@ import (
"net" "net"
"net/url" "net/url"
"reflect" "reflect"
"slices"
"time" "time"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -23,6 +24,8 @@ const (
postgresUTCCallback = "renewcms:postgres_utc" postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query" gormQueryCallback = "gorm:query"
postgresLockNotAvailable = "55P03" postgresLockNotAvailable = "55P03"
postgresDeadlockDetected = "40P01"
postgresSerializationFailure = "40001"
) )
var timeType = reflect.TypeOf(time.Time{}) var timeType = reflect.TypeOf(time.Time{})
@@ -109,7 +112,9 @@ func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.T
return NewPostgresBackup(db, directory, now) return NewPostgresBackup(db, directory, now)
} }
var postgresContentionCodes = []string{postgresLockNotAvailable, postgresDeadlockDetected, postgresSerializationFailure}
func (postgresDriver) IsLockContention(err error) bool { func (postgresDriver) IsLockContention(err error) bool {
var failure *pgconn.PgError var failure *pgconn.PgError
return errors.As(err, &failure) && failure.Code == postgresLockNotAvailable return errors.As(err, &failure) && slices.Contains(postgresContentionCodes, failure.Code)
} }
@@ -55,6 +55,9 @@ func ArticleToDomain(m models.Article) article.Article {
Lang: m.Lang, Lang: m.Lang,
StoryID: m.StoryID, StoryID: m.StoryID,
ContentRevision: m.ContentRevision, ContentRevision: m.ContentRevision,
EditRevision: m.EditRevision,
SourceArticleID: m.SourceArticleID,
SourceRevision: m.SourceRevision,
} }
} }
@@ -82,5 +85,8 @@ func ArticleToModel(a article.Article) models.Article {
Lang: a.Lang, Lang: a.Lang,
StoryID: a.StoryID, StoryID: a.StoryID,
ContentRevision: a.ContentRevision, ContentRevision: a.ContentRevision,
EditRevision: a.EditRevision,
SourceArticleID: a.SourceArticleID,
SourceRevision: a.SourceRevision,
} }
} }
@@ -31,4 +31,8 @@ type Article struct {
Lang string `gorm:"size:35;not null;default:''"` Lang string `gorm:"size:35;not null;default:''"`
StoryID uint32 `gorm:"not null;default:0"` StoryID uint32 `gorm:"not null;default:0"`
ContentRevision uint32 `gorm:"not null;default:1"` ContentRevision uint32 `gorm:"not null;default:1"`
EditRevision uint32 `gorm:"not null;default:1"`
SourceArticleID *uint32
SourceRevision uint32 `gorm:"not null;default:0"`
RetiredID uint32 `gorm:"column:retired_id;not null;default:0"`
} }
@@ -4,18 +4,23 @@ import (
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author" authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language" "RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"errors" "errors"
"slices"
"sort" "sort"
"strings" "strings"
"time"
"unicode" "unicode"
"golang.org/x/text/runes" "golang.org/x/text/runes"
"golang.org/x/text/transform" "golang.org/x/text/transform"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type ArticleRepository struct { type ArticleRepository struct {
@@ -217,11 +222,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil return loaded[0], nil
} }
var preservedArticleColumns = []string{"lang", "story_id", "content_revision"} var errArticleSiteChanged = errors.New("the article moved to another site while it was being locked")
const ( const (
idChunkSize = 500 idChunkSize = 500
initialContentRevision = 1 maxUpdateAttempts = 5
maxSiteLockRounds = 5
) )
func chunkIDs(ids []uint32) [][]uint32 { func chunkIDs(ids []uint32) [][]uint32 {
@@ -321,14 +327,16 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
} }
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) { func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := mappers.ArticleToModel(article) created := mappers.ArticleToModel(article.Opened())
created.ContentRevision = initialContentRevision
err := a.db.Transaction(func(tx *gorm.DB) error { err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 { if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil { if _, err := lockSite(tx, article.SiteID); err != nil {
return err return err
} }
} }
if err := ensureStoryLanguageFree(tx, created.StoryID, created.Lang); err != nil {
return err
}
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil { if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
return err return err
} }
@@ -346,6 +354,27 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created)) return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
} }
func ensureStoryLanguageFree(tx *gorm.DB, storyID uint32, lang string) error {
if storyID == 0 {
return nil
}
var stories int64
if err := tx.Model(&entity.Story{}).Where("id = ?", storyID).Count(&stories).Error; err != nil {
return err
}
if stories == 0 {
return domain.ErrArticleNotFound
}
var versions int64
if err := tx.Model(&entity.Article{}).Where("story_id = ? AND lang = ?", storyID, lang).Count(&versions).Error; err != nil {
return err
}
if versions > 0 {
return domain.ErrLanguageTaken
}
return nil
}
func openStory(tx *gorm.DB, created *entity.Article) error { func openStory(tx *gorm.DB, created *entity.Article) error {
if created.StoryID != 0 { if created.StoryID != 0 {
return nil return nil
@@ -408,44 +437,71 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
} }
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) { func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
var localArticle entity.Article return a.updateColumns(id, map[string]any{"body": body})
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error }
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.Body = body func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error columns["updated_at"] = time.Now()
if err != nil { if err := a.db.Model(&entity.Article{}).Where("id = ?", id).Updates(columns).Error; err != nil {
return domain.Article{}, err return domain.Article{}, err
} }
return a.Get(id)
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
} }
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) { func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
var updated domain.Article
var err error
for attempt := 0; attempt < maxUpdateAttempts; attempt++ {
updated, err = a.updateOnce(id, update)
if err == nil || !database.IsLockContention(a.db, err) {
break
}
}
return updated, err
}
func (a *ArticleRepository) updateOnce(id uint32, update domain.Update) (domain.Article, error) {
for round := 0; round < maxSiteLockRounds; round++ {
var probe entity.Article
if err := a.db.Model(&entity.Article{}).Select("id", "site_id").First(&probe, id).Error; err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
updated, err := a.updateAt(id, update, probe.SiteID)
if !errors.Is(err, errArticleSiteChanged) {
return updated, err
}
}
return domain.Article{}, transaction.ErrLockContention
}
func (a *ArticleRepository) updateAt(id uint32, update domain.Update, probedSiteID uint32) (domain.Article, error) {
var localArticle entity.Article var localArticle entity.Article
err := a.db.Transaction(func(tx *gorm.DB) error { err := a.db.Transaction(func(tx *gorm.DB) error {
if update.SiteID != nil { locked, err := lockArticleBehindSites(tx, id, update, probedSiteID)
if _, err := lockSite(tx, *update.SiteID); err != nil { if err != nil {
return err return err
}
} }
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil { localArticle = locked
return mapNotFoundErr(err, domain.ErrArticleNotFound) if update.IfRevision != nil && localArticle.EditRevision != *update.IfRevision {
} return &domain.RevisionConflictError{Current: localArticle.EditRevision}
if update.SiteID == nil && update.Slug != nil {
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
return err
}
} }
before := localArticle
applyUpdate(&localArticle, update) applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil { if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil { if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
return err return err
} }
} }
if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil { authorsChanged, err := authorsDiffer(tx, id, update.AuthorIDs)
if err != nil {
return err
}
impact := domain.ImpactOf(mappers.ArticleToDomain(before), mappers.ArticleToDomain(localArticle), authorsChanged)
revised := mappers.ArticleToDomain(localArticle).Revised(impact)
localArticle.EditRevision = revised.EditRevision
localArticle.ContentRevision = revised.ContentRevision
localArticle.UpdatedAt = time.Now()
if err := writeArticleColumns(tx, localArticle, before.EditRevision); err != nil {
return err return err
} }
if update.AuthorIDs != nil { if update.AuthorIDs != nil {
@@ -453,13 +509,97 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
} }
return nil return nil
}) })
if errors.Is(err, domain.ErrRevisionConflict) || errors.Is(err, errArticleSiteChanged) {
return domain.Article{}, err
}
if err != nil { if err != nil {
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err) return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
} }
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle)) return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
} }
func lockArticleBehindSites(tx *gorm.DB, id uint32, update domain.Update, probedSiteID uint32) (entity.Article, error) {
if update.SiteID != nil || update.Slug != nil {
if err := lockSitesInOrder(tx, probedSiteID, update.SiteID); err != nil {
return entity.Article{}, err
}
}
var locked entity.Article
if err := tx.Model(&entity.Article{}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
return entity.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if locked.SiteID != probedSiteID {
return entity.Article{}, errArticleSiteChanged
}
return locked, nil
}
func lockSitesInOrder(tx *gorm.DB, currentSiteID uint32, targetSiteID *uint32) error {
siteIDs := []uint32{currentSiteID}
if targetSiteID != nil {
siteIDs = append(siteIDs, *targetSiteID)
}
slices.Sort(siteIDs)
for _, siteID := range slices.Compact(siteIDs) {
if siteID == 0 && targetSiteID != nil {
continue
}
if _, err := lockSite(tx, siteID); err != nil {
return err
}
}
return nil
}
func authorsDiffer(tx *gorm.DB, articleID uint32, authorIDs *[]uint32) (bool, error) {
if authorIDs == nil {
return false, nil
}
var current []uint32
if err := tx.Model(&entity.ArticleAuthor{}).Where("article_id = ?", articleID).Order("position ASC, id ASC").Pluck("author_id", &current).Error; err != nil {
return false, err
}
return !slices.Equal(current, *authorIDs), nil
}
func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision uint32) error {
result := tx.Model(&entity.Article{}).Where("id = ? AND edit_revision = ?", written.ID, expectedRevision).Updates(map[string]any{
"title": written.Title,
"body": written.Body,
"site_id": written.SiteID,
"slug": written.Slug,
"excerpt": written.Excerpt,
"seo_title": written.SEOTitle,
"seo_description": written.SEODescription,
"cover_image_id": written.CoverImageID,
"cover_image_alt": written.CoverImageAlt,
"social_title": written.SocialTitle,
"social_description": written.SocialDescription,
"social_image_id": written.SocialImageID,
"canonical_url": written.CanonicalURL,
"noindex": written.Noindex,
"seo_dismissed": written.SEODismissed,
"edit_revision": written.EditRevision,
"content_revision": written.ContentRevision,
"updated_at": written.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return &domain.RevisionConflictError{Current: currentRevision(tx, written.ID)}
}
return nil
}
func currentRevision(tx *gorm.DB, id uint32) uint32 {
var revision uint32
_ = tx.Model(&entity.Article{}).Where("id = ?", id).Pluck("edit_revision", &revision).Error
return revision
}
func applyUpdate(target *entity.Article, update domain.Update) { func applyUpdate(target *entity.Article, update domain.Update) {
setString(&target.Title, update.Title)
setString(&target.Body, update.Body) setString(&target.Body, update.Body)
setString(&target.Slug, update.Slug) setString(&target.Slug, update.Slug)
setString(&target.Excerpt, update.Excerpt) setString(&target.Excerpt, update.Excerpt)
@@ -493,19 +633,7 @@ func setString(target *string, value *string) {
} }
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) { func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
var localArticle entity.Article return a.updateColumns(id, map[string]any{"is_online": isOnline})
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.IsOnline = isOnline
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
} }
func (a *ArticleRepository) Delete(id uint32) error { func (a *ArticleRepository) Delete(id uint32) error {
@@ -514,14 +642,60 @@ func (a *ArticleRepository) Delete(id uint32) error {
if err := tx.First(&found, id).Error; err != nil { if err := tx.First(&found, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound) return mapNotFoundErr(err, domain.ErrArticleNotFound)
} }
tombstone := domain.Tombstone(id) if found.SiteID != 0 {
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil { if _, err := lockSite(tx, found.SiteID); err != nil {
return err
}
}
retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil {
return err return err
} }
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil { if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err return err
} }
return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error if err := tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
return closeEmptyStory(tx, found.StoryID)
})
}
func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
if storyID == 0 {
return nil
}
var remaining int64
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Count(&remaining).Error; err != nil || remaining > 0 {
return err
}
return tx.Delete(&entity.Story{}, storyID).Error
}
func (a *ArticleRepository) ListByStory(storyID uint32) ([]domain.Article, error) {
var found []entity.Article
if err := a.db.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Find(&found).Error; err != nil {
return nil, err
}
return toDomainArticles(found), nil
}
func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
for _, id := range ids {
var moving entity.Article
if err := tx.First(&moving, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if err := ensureSlugFree(tx, siteID, moving.Lang, moving.Slug, id); err != nil {
return err
}
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil {
return err
}
}
return nil
}) })
} }
@@ -5,6 +5,8 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction" "RenewCMS/internal/domain/transaction"
@@ -24,6 +26,7 @@ type CreateArticleCommand struct {
Title string Title string
Body string Body string
Format article.BodyFormat Format article.BodyFormat
Lang string
Caller ContentCaller Caller ContentCaller
} }
@@ -59,6 +62,11 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
recordFailure(ctx, g.recorder, event, err) recordFailure(ctx, g.recorder, event, err)
return article.Article{}, err return article.Article{}, err
} }
requested, err := requestedLanguage(createArticle.Lang)
if err != nil {
recordFailure(ctx, g.recorder, event, err)
return article.Article{}, err
}
siteID, err := g.defaultSiteID() siteID, err := g.defaultSiteID()
if err != nil { if err != nil {
@@ -72,7 +80,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err return article.Article{}, err
} }
created, err := g.createWithFreeSlug(ctx, article.Article{ created, err := g.createWithFreeSlug(ctx, requested, article.Article{
Title: createArticle.Title, Title: createArticle.Title,
Body: createArticle.Body, Body: createArticle.Body,
IsOnline: false, IsOnline: false,
@@ -86,14 +94,21 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err return article.Article{}, err
} }
event.Target = articleTarget(created) event = articleEvent(audit.ActionArticleCreate, created)
g.recorder.Record(ctx, event) g.recorder.Record(ctx, event)
return renderArticle(g.bodyRenderer, created, createArticle.Format) return renderArticle(g.bodyRenderer, created, createArticle.Format)
} }
func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft article.Article) (article.Article, error) { func requestedLanguage(raw string) (string, error) {
if raw == "" {
return language.Unassigned, nil
}
return language.Normalize(raw)
}
func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ { for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
created, err := g.createInTransaction(ctx, draft) created, err := g.createInTransaction(ctx, requested, draft)
if !isSlugTakenError(err) { if !isSlugTakenError(err) {
return created, err return created, err
} }
@@ -101,13 +116,19 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft art
return article.Article{}, article.ErrSlugTaken return article.Article{}, article.ErrSlugTaken
} }
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft article.Article) (article.Article, error) { func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
var created article.Article var created article.Article
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error { err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
lang, err := tx.Languages.LockStartingLanguage(draft.SiteID) lang, err := tx.Languages.LockStartingLanguage(draft.SiteID)
if err != nil { if err != nil {
return err return err
} }
if requested != language.Unassigned {
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
if err != nil {
return err
}
}
draft.Lang = lang draft.Lang = lang
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt) draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
if err != nil { if err != nil {
@@ -119,6 +140,23 @@ func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft ar
return created, err return created, err
} }
func chooseLanguage(repository localization.Repository, siteID uint32, requested string) (string, error) {
rows, err := repository.Rows(siteID)
if err != nil {
return "", err
}
if localization.NeedsSetup(rows) {
return "", localization.ErrSetupRequired
}
if !localization.HasRow(rows, requested) {
return "", &article.LanguageNotEnabledError{}
}
if !localization.EnabledTag(rows, requested) {
return "", article.ErrLanguageUnavailable
}
return requested, nil
}
func articleTarget(a article.Article) audit.Target { func articleTarget(a article.Article) audit.Target {
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title) return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
} }
@@ -0,0 +1,254 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"slices"
)
type CreateTranslationUseCase struct {
transactor transaction.Transactor
articles article.Repository
localization localization.Repository
bodyRenderer article.BodyRenderer
recorder audit.Recorder
}
type CreateTranslationCommand struct {
ArticleID uint32
StoryID uint32
Lang string
Title string
Copy *bool
SourceArticleID *uint32
Format article.BodyFormat
Caller ContentCaller
}
type translationPlan struct {
base article.Article
tag string
title string
sourceID uint32
copy bool
}
func NewCreateTranslationUseCase(transactor transaction.Transactor, articles article.Repository, localizationRepository localization.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *CreateTranslationUseCase {
return &CreateTranslationUseCase{transactor: transactor, articles: articles, localization: localizationRepository, bodyRenderer: bodyRenderer, recorder: recorder}
}
func (u *CreateTranslationUseCase) CreateTranslation(ctx context.Context, command CreateTranslationCommand) (article.Article, error) {
if err := command.Caller.requireHolding(role.KeyContentCreate, builtintype.KeyArticle); err != nil {
return article.Article{}, err
}
base, versions, err := u.resolveBase(command)
if err != nil {
return article.Article{}, err
}
event := articleEvent(audit.ActionArticleCreate, base)
plan, err := u.plan(command, base, versions)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return article.Article{}, err
}
created, err := u.createWithRetry(ctx, command.Caller, plan)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return article.Article{}, err
}
u.recordCreated(ctx, created)
return renderArticle(u.bodyRenderer, created, command.Format)
}
func (u *CreateTranslationUseCase) resolveBase(command CreateTranslationCommand) (article.Article, []article.Article, error) {
if command.StoryID == 0 {
base, err := u.articles.Get(command.ArticleID)
if err != nil {
return article.Article{}, nil, err
}
if !command.Caller.seesArticle(base) {
return article.Article{}, nil, article.ErrArticleNotFound
}
versions, err := u.storyVersions(base)
return base, versions, err
}
versions, err := u.articles.ListByStory(command.StoryID)
if err != nil {
return article.Article{}, nil, err
}
readable := sortByCreation(command.Caller.readableVersions(versions))
if len(readable) == 0 {
return article.Article{}, nil, article.ErrArticleNotFound
}
return readable[0], versions, nil
}
func (u *CreateTranslationUseCase) storyVersions(base article.Article) ([]article.Article, error) {
if base.StoryID == 0 {
return []article.Article{base}, nil
}
return u.articles.ListByStory(base.StoryID)
}
func (u *CreateTranslationUseCase) plan(command CreateTranslationCommand, base article.Article, versions []article.Article) (translationPlan, error) {
if command.Copy == nil || (!*command.Copy && command.SourceArticleID != nil) {
return translationPlan{}, article.ErrInvalidTranslation
}
tag, err := language.Normalize(command.Lang)
if err != nil {
return translationPlan{}, err
}
if base.Lang == language.Unassigned {
return translationPlan{}, localization.ErrSetupRequired
}
plan := translationPlan{base: base, tag: tag, title: command.Title, copy: *command.Copy}
if plan.copy {
plan.sourceID, err = u.sourceID(command, base)
if err != nil {
return translationPlan{}, err
}
}
return plan, u.checkLanguage(command.Caller, base, tag, versions)
}
func (u *CreateTranslationUseCase) sourceID(command CreateTranslationCommand, base article.Article) (uint32, error) {
sourceID := base.ID
if command.SourceArticleID != nil {
sourceID = *command.SourceArticleID
}
source, err := u.articles.Get(sourceID)
if errors.Is(err, article.ErrArticleNotFound) || err == nil && !command.Caller.seesArticle(source) {
return 0, article.ErrSourceUnavailable
}
if err != nil {
return 0, err
}
if source.StoryID != base.StoryID {
return 0, article.ErrSourceOtherStory
}
return sourceID, nil
}
func (u *CreateTranslationUseCase) checkLanguage(caller ContentCaller, base article.Article, tag string, versions []article.Article) error {
rows, err := u.localization.Rows(base.SiteID)
if err != nil {
return err
}
if !localization.HasRow(rows, tag) {
return &article.LanguageNotEnabledError{}
}
if existing, found := existingVersion(versions, tag); found {
if caller.seesArticle(existing) {
return &article.LanguageExistsError{ArticleID: existing.ID}
}
return article.ErrLanguageUnavailable
}
if !localization.EnabledTag(rows, tag) {
return article.ErrLanguageUnavailable
}
return nil
}
func existingVersion(versions []article.Article, tag string) (article.Article, bool) {
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.Lang == tag })
if index < 0 {
return article.Article{}, false
}
return versions[index], true
}
func (u *CreateTranslationUseCase) createWithRetry(ctx context.Context, caller ContentCaller, plan translationPlan) (article.Article, error) {
var err error
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
var created article.Article
created, err = u.createInTransaction(ctx, caller, plan)
if err == nil {
return created, nil
}
if !isSlugTakenError(err) && !errors.Is(err, transaction.ErrLockContention) {
break
}
}
if errors.Is(err, article.ErrLanguageTaken) {
return article.Article{}, article.ErrLanguageUnavailable
}
return article.Article{}, err
}
func (u *CreateTranslationUseCase) createInTransaction(ctx context.Context, caller ContentCaller, plan translationPlan) (article.Article, error) {
var created article.Article
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
if err := tx.Sites.LockSite(plan.base.SiteID); err != nil {
return err
}
draft, err := u.draft(tx, caller, plan)
if err != nil {
return err
}
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
if err != nil {
return err
}
created, err = tx.Articles.Create(draft)
return err
})
return created, err
}
func (u *CreateTranslationUseCase) draft(tx transaction.Repositories, caller ContentCaller, plan translationPlan) (article.Article, error) {
base, err := tx.Articles.Get(plan.base.ID)
if err != nil {
return article.Article{}, err
}
if base.SiteID != plan.base.SiteID || base.StoryID != plan.base.StoryID {
return article.Article{}, transaction.ErrLockContention
}
draft := article.Article{
Title: plan.title,
SiteID: base.SiteID,
StoryID: base.StoryID,
Lang: plan.tag,
CreatedBy: caller.UserID,
SEODismissed: []string{},
}
if !plan.copy {
draft.Authors, err = bylinesOf(tx.Authors, caller.UserID)
return draft, err
}
source, err := tx.Articles.Get(plan.sourceID)
if errors.Is(err, article.ErrArticleNotFound) || err == nil && (!caller.seesArticle(source) || source.StoryID != base.StoryID) {
return article.Article{}, article.ErrSourceUnavailable
}
if err != nil {
return article.Article{}, err
}
return source.CopiedInto(draft), nil
}
func bylinesOf(authors author.Repository, userID uint32) ([]author.Byline, error) {
found, err := 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 (u *CreateTranslationUseCase) recordCreated(ctx context.Context, created article.Article) {
event := articleEvent(audit.ActionArticleCreate, created)
event.Params[audit.ParamStoryID] = created.StoryID
if created.SourceArticleID != nil {
event.Params[audit.ParamSourceArticleID] = *created.SourceArticleID
}
u.recorder.Record(ctx, event)
}
@@ -29,8 +29,7 @@ func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32, cal
return err return err
} }
event := audit.NewEvent(audit.ActionArticleDelete, audit.CategoryArticles) event := articleEvent(audit.ActionArticleDelete, target)
event.Target = articleTarget(target)
if err := g.articleRepository.Delete(id); err != nil { if err := g.articleRepository.Delete(id); err != nil {
recordFailure(ctx, g.recorder, event, err) recordFailure(ctx, g.recorder, event, err)
return err return err
@@ -0,0 +1,95 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"errors"
)
type DescribeArticleUseCase struct {
articles article.Repository
localization localization.Repository
}
type ArticleContext struct {
Capabilities []string
Translations []article.Translation
Source *article.Source
}
func NewDescribeArticleUseCase(articles article.Repository, localizationRepository localization.Repository) *DescribeArticleUseCase {
return &DescribeArticleUseCase{articles: articles, localization: localizationRepository}
}
func (d *DescribeArticleUseCase) Describe(art article.Article, caller ContentCaller) (ArticleContext, error) {
described := ArticleContext{Capabilities: caller.capabilitiesFor(art), Translations: []article.Translation{}}
if caller.isAnonymous() {
return described, nil
}
translations, err := d.translations(art, caller)
if err != nil {
return ArticleContext{}, err
}
described.Translations = translations
described.Source, err = d.source(art, caller)
if err != nil {
return ArticleContext{}, err
}
return described, nil
}
func (d *DescribeArticleUseCase) translations(art article.Article, caller ContentCaller) ([]article.Translation, error) {
versions := []article.Article{art}
if art.StoryID != 0 {
found, err := d.articles.ListByStory(art.StoryID)
if err != nil {
return nil, err
}
versions = found
}
rows, err := d.rows(art.SiteID)
if err != nil {
return nil, err
}
translations := []article.Translation{}
for _, version := range sortByLanguage(caller.readableVersions(versions), rows) {
translations = append(translations, article.Translation{
ID: version.ID,
Lang: version.Lang,
Title: version.Title,
IsOnline: version.IsOnline,
LanguageEnabled: d.languageEnabled(rows, version.Lang),
Capabilities: caller.capabilitiesFor(version),
})
}
return translations, nil
}
func (d *DescribeArticleUseCase) rows(siteID uint32) ([]localization.Row, error) {
return d.localization.Rows(siteID)
}
func (d *DescribeArticleUseCase) languageEnabled(rows []localization.Row, tag string) bool {
if tag == language.Unassigned {
return true
}
return localization.EnabledTag(rows, tag)
}
func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, error) {
if art.SourceArticleID == nil {
return nil, nil
}
origin, err := d.articles.Get(*art.SourceArticleID)
if errors.Is(err, article.ErrArticleNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !caller.seesArticle(origin) {
return nil, nil
}
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, nil
}
@@ -0,0 +1,253 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"slices"
)
const maxSiteLockRounds = 5
type MoveStoryUseCase struct {
transactor transaction.Transactor
articles article.Repository
recorder audit.Recorder
}
type MoveStoryCommand struct {
StoryID uint32
SiteKey string
Caller ContentCaller
SkipArticleID uint32
}
type StoryMover interface {
MoveStoryThen(ctx context.Context, command MoveStoryCommand, then func(transaction.Repositories) error) (MoveStoryResult, error)
}
type MoveStoryResult struct {
StoryID uint32
Site site.Site
ArticleIDs []uint32
}
type storyMove struct {
result MoveStoryResult
moved []article.Article
origin site.Site
readable []article.Article
}
var _ StoryMover = &MoveStoryUseCase{}
func NewMoveStoryUseCase(transactor transaction.Transactor, articles article.Repository, recorder audit.Recorder) *MoveStoryUseCase {
return &MoveStoryUseCase{transactor: transactor, articles: articles, recorder: recorder}
}
func (u *MoveStoryUseCase) MoveStory(ctx context.Context, command MoveStoryCommand) (MoveStoryResult, error) {
return u.MoveStoryThen(ctx, command, nil)
}
func (u *MoveStoryUseCase) MoveStoryThen(ctx context.Context, command MoveStoryCommand, then func(transaction.Repositories) error) (MoveStoryResult, error) {
if err := command.Caller.requireHolding(role.KeyContentUpdate, builtintype.KeyArticle); err != nil {
return MoveStoryResult{}, err
}
var move storyMove
var followUpErr error
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
var err error
if move, err = u.moveInTransaction(tx, command); err != nil {
return err
}
if then != nil {
followUpErr = then(tx)
}
return followUpErr
})
if followUpErr != nil {
return MoveStoryResult{}, err
}
if errors.Is(err, article.ErrSlugTaken) {
err = &article.SlugConflictError{}
}
if err != nil {
u.recordRefusal(ctx, command, err)
return MoveStoryResult{}, err
}
u.recordMoved(ctx, move)
return move.result, nil
}
func (u *MoveStoryUseCase) moveInTransaction(tx transaction.Repositories, command MoveStoryCommand) (storyMove, error) {
versions, readable, err := authorizedVersions(tx, command)
if err != nil {
return storyMove{}, err
}
destination, err := tx.Sites.GetByKey(command.SiteKey)
if err != nil {
return storyMove{}, err
}
if versions, readable, err = lockStorySites(tx, command, destination, versions, readable); err != nil {
return storyMove{}, err
}
origin, err := tx.Sites.Get(readable[0].SiteID)
if err != nil {
return storyMove{}, err
}
move := storyMove{origin: origin, readable: readable, result: MoveStoryResult{StoryID: command.StoryID, Site: destination, ArticleIDs: idsOf(readable)}}
if origin.ID == destination.ID {
return move, nil
}
if err := checkDestination(tx, destination, versions, readable); err != nil {
return storyMove{}, err
}
ordered := withoutArticle(sortByCreation(versions), command.SkipArticleID)
if err := tx.Articles.MoveToSite(idsOf(ordered), destination.ID); err != nil {
return storyMove{}, err
}
move.moved = ordered
return move, nil
}
func withoutArticle(versions []article.Article, skipID uint32) []article.Article {
return slices.DeleteFunc(slices.Clone(versions), func(version article.Article) bool { return version.ID == skipID })
}
func lockStorySites(tx transaction.Repositories, command MoveStoryCommand, destination site.Site, versions []article.Article, readable []article.Article) ([]article.Article, []article.Article, error) {
locked := []uint32{}
for attempt := 0; attempt < maxSiteLockRounds; attempt++ {
needed := append(siteIDsOf(versions), destination.ID)
if allLocked(locked, needed) {
return versions, readable, nil
}
if err := lockSites(tx, needed); err != nil {
return nil, nil, err
}
locked = needed
var err error
if versions, readable, err = authorizedVersions(tx, command); err != nil {
return nil, nil, err
}
}
return nil, nil, transaction.ErrLockContention
}
func allLocked(locked []uint32, needed []uint32) bool {
for _, siteID := range needed {
if !slices.Contains(locked, siteID) {
return false
}
}
return true
}
func authorizedVersions(tx transaction.Repositories, command MoveStoryCommand) ([]article.Article, []article.Article, error) {
versions, err := tx.Articles.ListByStory(command.StoryID)
if err != nil {
return nil, nil, err
}
readable := sortByCreation(command.Caller.readableVersions(versions))
if len(readable) == 0 {
return nil, nil, article.ErrArticleNotFound
}
return versions, readable, requireUpdateOnAll(command.Caller, versions)
}
func siteIDsOf(versions []article.Article) []uint32 {
siteIDs := []uint32{}
for _, version := range versions {
siteIDs = append(siteIDs, version.SiteID)
}
return siteIDs
}
func lockSites(tx transaction.Repositories, siteIDs []uint32) error {
ordered := slices.Clone(siteIDs)
slices.Sort(ordered)
for _, siteID := range slices.Compact(ordered) {
if err := tx.Sites.LockSite(siteID); err != nil {
return err
}
}
return nil
}
func requireUpdateOnAll(caller ContentCaller, versions []article.Article) error {
for _, version := range versions {
if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, version.CreatedBy); err != nil {
return err
}
}
return nil
}
func checkDestination(tx transaction.Repositories, destination site.Site, versions []article.Article, readable []article.Article) error {
rows, err := tx.Localization.Rows(destination.ID)
if err != nil {
return err
}
if missing := localization.MissingTags(rows, languagesOf(versions)); len(missing) > 0 {
return &article.LanguageNotEnabledError{Missing: visibleTags(missing, readable)}
}
conflicting := []uint32{}
taken := false
for _, version := range sortByCreation(versions) {
clash, err := tx.Articles.SlugTaken(destination.ID, version.Lang, version.Slug, 0)
if err != nil {
return err
}
taken = taken || clash
if clash && slices.ContainsFunc(readable, func(candidate article.Article) bool { return candidate.ID == version.ID }) {
conflicting = append(conflicting, version.ID)
}
}
if taken {
return &article.SlugConflictError{ArticleIDs: conflicting}
}
return nil
}
func visibleTags(missing []string, readable []article.Article) []string {
visible := []string{}
for _, tag := range missing {
if slices.Contains(languagesOf(readable), tag) {
visible = append(visible, tag)
}
}
return visible
}
func idsOf(versions []article.Article) []uint32 {
ids := make([]uint32, 0, len(versions))
for _, version := range versions {
ids = append(ids, version.ID)
}
return ids
}
func (u *MoveStoryUseCase) recordMoved(ctx context.Context, move storyMove) {
for _, version := range move.moved {
event := articleEvent(audit.ActionArticleEdit, version)
event.Changes = []audit.Change{audit.TextChange(audit.FieldSite, move.origin.Name, move.result.Site.Name)}
u.recorder.Record(ctx, event)
}
}
func (u *MoveStoryUseCase) recordRefusal(ctx context.Context, command MoveStoryCommand, cause error) {
if isNotFound(cause) || errors.Is(cause, role.ErrMissingPermission) {
return
}
versions, err := u.articles.ListByStory(command.StoryID)
readable := sortByCreation(command.Caller.readableVersions(versions))
if err != nil || len(readable) == 0 {
return
}
recordFailure(ctx, u.recorder, articleEvent(audit.ActionArticleEdit, readable[0]), cause)
}
@@ -6,8 +6,11 @@ import (
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"context" "context"
"slices" "slices"
"strconv" "strconv"
@@ -21,9 +24,13 @@ type UpdateArticleUseCase struct {
sites site.Repository sites site.Repository
authors author.Repository authors author.Repository
images image.Repository images image.Repository
localization localization.Repository
storyMover StoryMover
} }
type UpdateArticleCommand struct { type UpdateArticleCommand struct {
Title *string
IfRevision *uint32
Body *string Body *string
SiteID *uint32 SiteID *uint32
AuthorIDs *[]uint32 AuthorIDs *[]uint32
@@ -42,8 +49,8 @@ type UpdateArticleCommand struct {
Format article.BodyFormat Format article.BodyFormat
} }
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository, images image.Repository) *UpdateArticleUseCase { func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository, images image.Repository, localizationRepository localization.Repository, storyMover StoryMover) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images} return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images, localization: localizationRepository, storyMover: storyMover}
} }
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, caller ContentCaller, body string, format article.BodyFormat) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, caller ContentCaller, body string, format article.BodyFormat) (article.Article, error) {
@@ -61,10 +68,29 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, before.CreatedBy); err != nil { if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, before.CreatedBy); err != nil {
return article.Article{}, err return article.Article{}, err
} }
if !caller.seesArticle(before) {
return article.Article{}, article.ErrArticleNotFound
}
if command.IfRevision != nil && before.EditRevision != *command.IfRevision {
err := &article.RevisionConflictError{Current: before.EditRevision}
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
}
if command.Title != nil {
if err := article.ValidateTitle(*command.Title); err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
}
}
destination, err := g.destinationSite(command) destination, err := g.destinationSite(command)
if err != nil { if err != nil {
return article.Article{}, err return article.Article{}, err
} }
movesWholeStory, err := g.checkSingleVersionMove(before, caller, command)
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
}
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil { if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
return article.Article{}, err return article.Article{}, err
} }
@@ -78,7 +104,28 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
return article.Article{}, err return article.Article{}, err
} }
update.Slug = slug update.Slug = slug
if movesWholeStory {
return g.updateWithStory(ctx, id, caller, before, command, destination, update, slug)
}
updated, err := g.articleRepository.Update(id, update) updated, err := g.articleRepository.Update(id, update)
return g.finishEdit(ctx, before, command, slug, destination, updated, err)
}
func (g *UpdateArticleUseCase) updateWithStory(ctx context.Context, id uint32, caller ContentCaller, before article.Article, command UpdateArticleCommand, destination *site.Site, update article.Update, slug *string) (article.Article, error) {
var updated article.Article
var writeErr error
move := MoveStoryCommand{StoryID: before.StoryID, SiteKey: destination.Key, Caller: caller, SkipArticleID: id}
_, err := g.storyMover.MoveStoryThen(ctx, move, func(tx transaction.Repositories) error {
updated, writeErr = tx.Articles.Update(id, update)
return writeErr
})
if err != nil && writeErr == nil {
return article.Article{}, err
}
return g.finishEdit(ctx, before, command, slug, destination, updated, err)
}
func (g *UpdateArticleUseCase) finishEdit(ctx context.Context, before article.Article, command UpdateArticleCommand, slug *string, destination *site.Site, updated article.Article, err error) (article.Article, error) {
if isSlugTakenError(err) { if isSlugTakenError(err) {
err = g.raceConflict(before, command, slug) err = g.raceConflict(before, command, slug)
} }
@@ -92,6 +139,8 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
func (c UpdateArticleCommand) toUpdate() article.Update { func (c UpdateArticleCommand) toUpdate() article.Update {
update := article.Update{ update := article.Update{
Title: c.Title,
IfRevision: c.IfRevision,
Body: c.Body, Body: c.Body,
SiteID: c.SiteID, SiteID: c.SiteID,
AuthorIDs: c.AuthorIDs, AuthorIDs: c.AuthorIDs,
@@ -188,6 +237,47 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s
return &destination, nil return &destination, nil
} }
func (g *UpdateArticleUseCase) checkSingleVersionMove(before article.Article, caller ContentCaller, command UpdateArticleCommand) (bool, error) {
if command.SiteID == nil || *command.SiteID == before.SiteID {
return false, nil
}
versions, err := g.articleRepository.ListByStory(before.StoryID)
if err != nil {
return false, err
}
if len(caller.readableVersions(versions)) > 1 {
return false, article.ErrStoryMoveRequired
}
if err := requireUpdateOnAll(caller, versions); err != nil {
return false, err
}
if len(versions) > 1 {
return true, nil
}
rows, err := g.localization.Rows(*command.SiteID)
if err != nil {
return false, err
}
if missing := localization.MissingTags(rows, []string{before.Lang}); len(missing) > 0 {
return false, &article.LanguageNotEnabledError{Missing: missing}
}
return false, nil
}
func (g *UpdateArticleUseCase) checkPublishable(before article.Article, isOnline bool) error {
if !isOnline || before.IsOnline || before.Lang == language.Unassigned {
return nil
}
rows, err := g.localization.Rows(before.SiteID)
if err != nil {
return err
}
if !localization.EnabledTag(rows, before.Lang) {
return article.ErrLanguageDisabled
}
return nil
}
func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error { func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
if ids == nil { if ids == nil {
return nil return nil
@@ -230,8 +320,7 @@ func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Ar
if len(changes) == 0 { if len(changes) == 0 {
return return
} }
event := audit.NewEvent(audit.ActionArticleEdit, audit.CategoryArticles) event := articleEvent(audit.ActionArticleEdit, after)
event.Target = articleTarget(after)
event.Changes = changes event.Changes = changes
g.recorder.Record(ctx, event) g.recorder.Record(ctx, event)
} }
@@ -266,6 +355,10 @@ func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, ca
if err := caller.authorize(role.KeyContentPublish, builtintype.KeyArticle, before.CreatedBy); err != nil { if err := caller.authorize(role.KeyContentPublish, builtintype.KeyArticle, before.CreatedBy); err != nil {
return article.Article{}, err return article.Article{}, err
} }
if err := g.checkPublishable(before, isOnline); err != nil {
g.recordFailure(ctx, publishAction(isOnline), before, err)
return article.Article{}, err
}
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline) updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
if err != nil { if err != nil {
g.recordFailure(ctx, publishAction(isOnline), before, err) g.recordFailure(ctx, publishAction(isOnline), before, err)
@@ -287,14 +380,11 @@ func (g *UpdateArticleUseCase) recordChange(ctx context.Context, action string,
if len(changes) == 0 { if len(changes) == 0 {
return return
} }
event := audit.NewEvent(action, audit.CategoryArticles) event := articleEvent(action, after)
event.Target = articleTarget(after)
event.Changes = changes event.Changes = changes
g.recorder.Record(ctx, event) g.recorder.Record(ctx, event)
} }
func (g *UpdateArticleUseCase) recordFailure(ctx context.Context, action string, before article.Article, cause error) { func (g *UpdateArticleUseCase) recordFailure(ctx context.Context, action string, before article.Article, cause error) {
event := audit.NewEvent(action, audit.CategoryArticles) recordFailure(ctx, g.recorder, articleEvent(action, before), cause)
event.Target = articleTarget(before)
recordFailure(ctx, g.recorder, event, cause)
} }
@@ -0,0 +1,83 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"cmp"
"slices"
)
func (c ContentCaller) isAnonymous() bool {
return c.UserID == 0
}
func (c ContentCaller) capabilitiesFor(art article.Article) []string {
capabilities := []string{article.CapabilityRead}
if c.Grants.Permits(role.KeyContentUpdate, builtintype.KeyArticle, c.UserID, art.CreatedBy) {
capabilities = append(capabilities, article.CapabilityEdit)
}
if c.Grants.Permits(role.KeyContentPublish, builtintype.KeyArticle, c.UserID, art.CreatedBy) {
capabilities = append(capabilities, article.CapabilityPublish)
}
if c.Grants.Permits(role.KeyContentDelete, builtintype.KeyArticle, c.UserID, art.CreatedBy) {
capabilities = append(capabilities, article.CapabilityDelete)
}
if c.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle) {
capabilities = append(capabilities, article.CapabilityCreateTranslation)
}
return capabilities
}
func (c ContentCaller) readableVersions(versions []article.Article) []article.Article {
readable := make([]article.Article, 0, len(versions))
for _, version := range versions {
if c.seesArticle(version) {
readable = append(readable, version)
}
}
return readable
}
func sortByCreation(versions []article.Article) []article.Article {
ordered := slices.Clone(versions)
slices.SortFunc(ordered, func(left article.Article, right article.Article) int {
if byTime := left.CreatedAt.Compare(right.CreatedAt); byTime != 0 {
return byTime
}
return cmp.Compare(left.ID, right.ID)
})
return ordered
}
func sortByLanguage(versions []article.Article, rows []localization.Row) []article.Article {
defaultTag := localization.DefaultTag(rows)
ordered := slices.Clone(versions)
slices.SortFunc(ordered, func(left article.Article, right article.Article) int {
if byTag := localization.CompareTags(defaultTag, left.Lang, right.Lang); byTag != 0 {
return byTag
}
return cmp.Compare(left.ID, right.ID)
})
return ordered
}
func languagesOf(versions []article.Article) []string {
tags := make([]string, 0, len(versions))
for _, version := range versions {
tags = append(tags, version.Lang)
}
return tags
}
func articleEvent(action string, art article.Article) audit.Event {
event := audit.NewEvent(action, audit.CategoryArticles)
event.Target = articleTarget(art)
if art.Lang != language.Unassigned {
event.Params[audit.ParamLang] = art.Lang
}
return event
}
@@ -28,6 +28,15 @@ type failureReason struct {
var failureReasons = []failureReason{ var failureReasons = []failureReason{
{article.ErrInvalidTitle, "The title must be between 3 and 200 characters."}, {article.ErrInvalidTitle, "The title must be between 3 and 200 characters."},
{article.ErrSlugTaken, "That slug is already used by another article on this site."}, {article.ErrSlugTaken, "That slug is already used by another article on this site."},
{article.ErrRevisionConflict, "The article was changed by someone else since it was opened."},
{article.ErrLanguageUnavailable, "That language cannot be used for this article."},
{article.ErrLanguageExists, "A version in that language already exists."},
{article.ErrLanguageNotEnabled, "A language of the article is not enabled on the site."},
{article.ErrLanguageDisabled, "That language is disabled on the site."},
{article.ErrSourceUnavailable, "The chosen source version cannot be used."},
{article.ErrSourceOtherStory, "The chosen source version belongs to another article."},
{article.ErrStoryMoveRequired, "The article has other versions, so the whole story must be moved."},
{article.ErrSlugConflict, "An address of the article is already used on the destination site."},
{image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."}, {image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."},
{image.ErrImageTooLarge, "The file is too large to upload."}, {image.ErrImageTooLarge, "The file is too large to upload."},
{image.ErrImageUnreadable, "The image could not be read."}, {image.ErrImageUnreadable, "The image could not be read."},
+1 -1
View File
@@ -26,7 +26,7 @@ repo="$work/repo"
mkdir -p "$repo" "$repo/tmp" "$repo/.git" "$work/tmp" "$root/tmp" mkdir -p "$repo" "$repo/tmp" "$repo/.git" "$work/tmp" "$root/tmp"
tar -C "$root" -c \ tar -C "$root" -c \
--exclude=./bin --exclude=./web --exclude=./node_modules --exclude=./.git \ --exclude=./bin --exclude=./web/node_modules --exclude=./web/dist --exclude=./node_modules --exclude=./.git \
--exclude=./.task --exclude=./tmp --exclude=./data --exclude=./.claude \ --exclude=./.task --exclude=./tmp --exclude=./data --exclude=./.claude \
--exclude=./internal/infrastructure/assets/dist --exclude='*.exe' --exclude='*.db' . | --exclude=./internal/infrastructure/assets/dist --exclude='*.exe' --exclude='*.db' . |
tar -C "$repo" -x tar -C "$repo" -x
+2 -2
View File
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)} repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil { if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"} repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), "héllo wörld", article.BodyFormatHTML); err != nil { if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), "héllo wörld", article.BodyFormatHTML); err != nil {
t.Fatal(err) t.Fatal(err)
+2
View File
@@ -68,6 +68,8 @@ var routesNotYetInTheCatalogue = []string{
"POST /v1/site/transfer-ownership", "POST /v1/site/transfer-ownership",
"PUT /v1/role/{id}", "PUT /v1/role/{id}",
"PUT /v1/user/{id}/role", "PUT /v1/user/{id}/role",
"POST /v1/article/{id}/translations",
"POST /v1/story/{story_id}/move",
} }
func TestRouterRoutesAreInTheCatalogue(t *testing.T) { func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
+3 -1
View File
@@ -112,7 +112,9 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()), CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images, newFakeLocalizationStore(env.articles), plainStoryMover(env.articles)),
CreateTranslationUseCase: plainTranslation(env.articles),
DescribeArticleUseCase: plainDescribe(env.articles),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
+3 -1
View File
@@ -47,7 +47,9 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+3 -1
View File
@@ -27,7 +27,9 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+8 -2
View File
@@ -39,7 +39,9 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}), GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(e.articles), plainStoryMover(e.articles)),
CreateTranslationUseCase: plainTranslation(e.articles),
DescribeArticleUseCase: plainDescribe(e.articles),
} }
router := chi.NewRouter() router := chi.NewRouter()
router.Use(authtoken.Verifier(e.token)) router.Use(authtoken.Verifier(e.token))
@@ -215,11 +217,15 @@ func TestArticleCreatedByHttp(t *testing.T) {
} }
}) })
t.Run("anonymous and signed-in encodings differ only by created_by", func(t *testing.T) { t.Run("anonymous and signed-in encodings differ only by created_by and capabilities", func(t *testing.T) {
e, router := newCreatedByEnv(t) e, router := newCreatedByEnv(t)
anonymous := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "").Body.Bytes()) anonymous := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "").Body.Bytes())
signedIn := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID)).Body.Bytes()) signedIn := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID)).Body.Bytes())
delete(signedIn, "created_by") delete(signedIn, "created_by")
delete(signedIn, "capabilities")
delete(signedIn, "translations")
delete(anonymous, "translations")
delete(anonymous, "capabilities")
if len(anonymous) != len(signedIn) { if len(anonymous) != len(signedIn) {
t.Fatalf("field sets differ: %d vs %d", len(anonymous), len(signedIn)) t.Fatalf("field sets differ: %d vs %d", len(anonymous), len(signedIn))
} }
+427
View File
@@ -0,0 +1,427 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
func revisionsOf(w *storyWorld, id uint32) (uint32, uint32) {
stored := w.stored(id)
return stored.EditRevision, stored.ContentRevision
}
func TestArticleEditRevisionHTTP(t *testing.T) {
t.Run("Title boundaries on PATCH, counted in runes", func(t *testing.T) {
cases := []struct {
name string
title string
status int
message string
stored bool
}{
{"2 ASCII characters", `"ab"`, 400, apperr.ArticleTitleErrorMessage, false},
{"3 ASCII characters", `"abc"`, 200, "", true},
{"200 ASCII characters", `"` + strings.Repeat("a", 200) + `"`, 200, "", true},
{"201 ASCII characters", `"` + strings.Repeat("a", 201) + `"`, 400, apperr.ArticleTitleErrorMessage, false},
{"200 four-byte runes", `"` + strings.Repeat("😀", 200) + `"`, 200, "", true},
{"empty", `""`, 400, apperr.ArticleTitleErrorMessage, false},
{"null", `null`, 400, apperr.BodyErrorMessage, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":`+c.title+`}`)
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
}
if changed := w.stored(10).Title != "Renovation costs"; changed != c.stored {
t.Errorf("stored title changed = %v, expected %v", changed, c.stored)
}
})
}
})
t.Run("A domain title rejection on update is a 400, not a 500", func(t *testing.T) {
w := newStoryWorld(t)
w.articles.fail.failNextWith("Update", article.ErrInvalidTitle)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Valid title"}`).expect(t, 400, apperr.ArticleTitleErrorMessage)
})
t.Run("Editing the title never changes the slug", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title"}`)
response.expect(t, 200, "")
if stored := w.stored(10); stored.Slug != "renovation-costs" || !stored.IsOnline {
t.Errorf("slug and publication must be unchanged: %+v", stored)
}
})
t.Run("Title and body in one request", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title","body":"<p>x</p>"}`)
response.expect(t, 200, "")
if stored := w.stored(10); stored.Title != "New title" || stored.Body != "<p>x</p>" || stored.EditRevision != 4 {
t.Errorf("both must be stored with revision 4: %+v", stored)
}
})
t.Run("The title is editable on a single-language site", func(t *testing.T) {
w := newStoryWorld(t)
w.sites.seed(3, "Archive", "archive", false)
w.rows.rows[3] = storyRows("en", []string{"en"}, nil)
w.put(article.Article{ID: 60, Title: "Old archive", SiteID: 3, CreatedBy: storyWriter1, Slug: "old-archive", Lang: "en", StoryID: 60})
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/60", `{"title":"New title"}`)
response.expect(t, 200, "")
if w.stored(60).Slug != "old-archive" {
t.Errorf("the slug must be unchanged")
}
})
t.Run("Explicit slug edits", func(t *testing.T) {
cases := []struct {
name string
id string
body string
status int
message string
slug string
header string
}{
{"online article", "10", `{"slug":"new-address"}`, 200, "", "new-address", ""},
{"offline article", "12", `{"slug":"new-address"}`, 200, "", "new-address", ""},
{"same as current", "10", `{"slug":"renovation-costs"}`, 200, "", "renovation-costs", ""},
{"not a slug", "10", `{"slug":"Not A Slug"}`, 400, apperr.InvalidSlugErrorMessage, "renovation-costs", ""},
{"used by another language of the same story", "10", `{"slug":"couts"}`, 200, "", "couts", ""},
{"used by another en article", "10", `{"slug":"taken-en"}`, 409, apperr.ArticleSlugTakenErrorMessage, "renovation-costs", "taken-en-2"},
{"empty regenerates from the title", "10", `{"slug":""}`, 200, "", "renovation-costs", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Taken", SiteID: storyJournal, CreatedBy: 1, Slug: "taken-en", Lang: "en", StoryID: 70})
id, _ := strconv.ParseUint(c.id, 10, 32)
editBefore, _ := revisionsOf(w, uint32(id))
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/"+c.id, c.body)
response.expect(t, c.status, c.message)
if got := w.stored(uint32(id)).Slug; got != c.slug {
t.Errorf("slug %q, expected %q", got, c.slug)
}
if response.header(apperr.SlugSuggestionHeader) != c.header {
t.Errorf("X-Slug-Suggestion %q, expected %q", response.header(apperr.SlugSuggestionHeader), c.header)
}
if editAfter, _ := revisionsOf(w, uint32(id)); (c.name == "same as current" || c.name == "empty regenerates from the title") && editAfter != editBefore {
t.Errorf("a no-op slug must not change the revision")
}
if w.stored(uint32(id)).IsOnline != (c.id == "10") {
t.Errorf("publication must be unchanged")
}
})
}
})
t.Run("The same slug in two languages is not a conflict", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"slug":"couts"}`).expect(t, 200, "")
if w.stored(10).Slug != "couts" || w.stored(12).Slug != "couts" {
t.Errorf("both articles must hold the slug couts")
}
})
t.Run("Revision is exposed on every article read and write", func(t *testing.T) {
w := newStoryWorld(t)
requests := []struct {
method string
path string
body string
status int
}{
{http.MethodGet, "/v1/article/10", "", 200},
{http.MethodGet, "/v1/article/", "", 200},
{http.MethodPost, "/v1/article/", `{"title":"Plain draft"}`, 201},
{http.MethodPatch, "/v1/article/10", `{"body":"x"}`, 200},
{http.MethodPatch, "/v1/article/12/publish", "", 200},
{http.MethodPatch, "/v1/article/12/unpublish", "", 200},
}
for _, request := range requests {
response := w.send(storyOwner, request.method, request.path, request.body)
if response.Code != request.status {
t.Fatalf("%s %s: status %d %s", request.method, request.path, response.Code, response.text())
}
if strings.HasSuffix(request.path, "/") && request.method == http.MethodGet {
for _, item := range decodeArticleItems(t, response.Body.Bytes()) {
assertPositiveRevision(t, item)
}
continue
}
fields := response.fields(t)
assertPositiveRevision(t, fields)
for _, key := range legacyArticleKeys {
if _, present := fields[key]; !present {
t.Errorf("%s %s: legacy key %q missing", request.method, request.path, key)
}
}
}
})
fieldCases := []struct {
field string
body string
content bool
setup func(*storyWorld)
}{
{"title", `{"title":"Changed title"}`, true, nil},
{"body", `{"body":"<p>changed</p>"}`, true, nil},
{"excerpt", `{"excerpt":"changed"}`, true, nil},
{"slug", `{"slug":"new-address"}`, true, nil},
{"cover_image_alt", `{"cover_image_alt":"changed"}`, true, nil},
{"seo_title", `{"seo_title":"changed"}`, true, nil},
{"seo_description", `{"seo_description":"changed"}`, true, nil},
{"social_title", `{"social_title":"changed"}`, true, nil},
{"social_description", `{"social_description":"changed"}`, true, nil},
{"author_ids", `{"author_ids":[2]}`, false, nil},
{"cover_image_id", `{"cover_image_id":7}`, false, nil},
{"social_image_id", `{"social_image_id":8}`, false, nil},
{"canonical_url", `{"canonical_url":"https://journal.example/other"}`, false, nil},
{"noindex", `{"noindex":false}`, false, nil},
{"seo_dismissed", `{"seo_dismissed":["cover_alt"]}`, false, nil},
}
t.Run("What raises revision and content_revision", func(t *testing.T) {
for _, c := range fieldCases {
t.Run(c.field, func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", c.body).expect(t, 200, "")
edit, content := revisionsOf(w, 10)
wantContent := uint32(4)
if c.content {
wantContent = 5
}
if edit != 4 || content != wantContent {
t.Errorf("revision %d and content_revision %d, expected 4 and %d", edit, content, wantContent)
}
})
}
t.Run("site_id of a single-version story", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 60, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 60, EditRevision: 3, ContentRevision: 4})
w.send(storyUpdater, http.MethodPatch, "/v1/article/60", `{"site_id":2}`).expect(t, 200, "")
if edit, content := revisionsOf(w, 60); edit != 4 || content != 4 {
t.Errorf("revision %d and content_revision %d, expected 4 and 4", edit, content)
}
})
})
t.Run("Several content fields in one PATCH raise each counter once", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Changed title","body":"<p>b</p>","excerpt":"e"}`).expect(t, 200, "")
if edit, content := revisionsOf(w, 10); edit != 4 || content != 5 {
t.Errorf("revision %d and content_revision %d, expected 4 and 5", edit, content)
}
})
t.Run("A PATCH that changes nothing leaves both counters alone", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Renovation costs","body":"<p>Costs</p>","noindex":true}`).expect(t, 200, "")
if edit, content := revisionsOf(w, 10); edit != 3 || content != 4 {
t.Errorf("revision %d and content_revision %d, expected 3 and 4", edit, content)
}
})
t.Run("Publishing and unpublishing do not touch revisions", func(t *testing.T) {
w := newStoryWorld(t)
editBefore, contentBefore := revisionsOf(w, 12)
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
if edit, content := revisionsOf(w, 12); edit != editBefore || content != contentBefore {
t.Errorf("revisions changed to %d and %d", edit, content)
}
})
t.Run("if_revision matches", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":3}`)
response.expect(t, 200, "")
if string(response.fields(t)["revision"]) != "4" {
t.Errorf("the response revision must be 4: %s", response.text())
}
})
t.Run("if_revision is stale", func(t *testing.T) {
w := newStoryWorld(t)
stored := w.stored(10)
stored.EditRevision = 4
w.articles.articles[10] = stored
before := w.snapshot()
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":3}`)
response.expect(t, 409, apperr.RevisionConflictErrorMessage)
if response.header(apperr.CurrentRevisionHeader) != "4" {
t.Errorf("X-Current-Revision %q", response.header(apperr.CurrentRevisionHeader))
}
w.expectUnchanged(before)
for _, event := range w.eventsOf(audit.ActionArticleEdit) {
if event.Outcome == audit.OutcomeSuccess {
t.Errorf("no successful edit event expected: %+v", event)
}
}
})
t.Run("A stale if_revision wins over a no-op PATCH", func(t *testing.T) {
w := newStoryWorld(t)
stored := w.stored(10)
stored.EditRevision = 4
w.articles.articles[10] = stored
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Renovation costs","if_revision":3}`)
response.expect(t, 409, apperr.RevisionConflictErrorMessage)
})
t.Run("Overwrite uses the revision the conflict dialog showed", func(t *testing.T) {
w := newStoryWorld(t)
stored := w.stored(10)
stored.EditRevision = 4
w.articles.articles[10] = stored
overwrite := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"mine","if_revision":4}`)
overwrite.expect(t, 200, "")
if string(overwrite.fields(t)["revision"]) != "5" {
t.Errorf("revision must be 5: %s", overwrite.text())
}
w.articles.beforeUpdate = func() {
saved := w.stored(10)
saved.EditRevision = 6
saved.Body = "theirs"
w.articles.articles[10] = saved
}
raced := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"again","if_revision":5}`)
raced.expect(t, 409, apperr.RevisionConflictErrorMessage)
if raced.header(apperr.CurrentRevisionHeader) != "6" || w.stored(10).Body != "theirs" {
t.Errorf("the then-current revision 6 and their body expected: %q %q", raced.header(apperr.CurrentRevisionHeader), w.stored(10).Body)
}
})
t.Run("Without if_revision the last write wins", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x"}`).expect(t, 200, "")
if edit, _ := revisionsOf(w, 10); edit != 4 {
t.Errorf("revision still rises by 1, got %d", edit)
}
})
t.Run("Invalid if_revision", func(t *testing.T) {
for _, value := range []string{`0`, `-1`, `"3"`, `1.5`, `null`} {
t.Run(value, func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":`+value+`}`).expect(t, 400, apperr.BodyErrorMessage)
})
}
})
t.Run("if_revision alone is not an update", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"if_revision":3}`).expect(t, 400, apperr.BodyErrorMessage)
})
t.Run("Other languages are untouched by an overwrite", func(t *testing.T) {
w := newStoryWorld(t)
before := w.stored(12)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":3}`).expect(t, 200, "")
if !articlesEqual(before, w.stored(12)) {
t.Errorf("article 12 changed")
}
})
t.Run("Permissions are unchanged", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyReader, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":1}`).expect(t, 403, apperr.MissingPermissionErrorMessage)
w.send(storyAnon, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":1}`).expect(t, 401, "")
w.send(storyUpdater, http.MethodPatch, "/v1/article/9999", `{"body":"x"}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage)
before := w.snapshot()
hidden := w.send(storyPartial, http.MethodPatch, "/v1/article/11", `{"body":"x","if_revision":99}`)
hidden.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
if hidden.header("X-Current-Revision") != "" {
t.Errorf("a caller who cannot read the article must not learn its revision")
}
w.send(storyPartial, http.MethodPatch, "/v1/article/11", `{"body":"x"}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage)
w.expectUnchanged(before)
})
t.Run("Editing an online article without content.publish changes the live page right away", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title"}`).expect(t, 200, "")
public := w.send(storyAnon, http.MethodGet, "/v1/article/10", "")
public.expect(t, 200, "")
if string(public.fields(t)["title"]) != `"New title"` {
t.Errorf("the public read must show the new title: %s", public.text())
}
})
t.Run("Title and slug edits are recorded", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title","slug":"new-address"}`).expect(t, 200, "")
events := w.eventsOf(audit.ActionArticleEdit)
if len(events) != 1 {
t.Fatalf("one edit event expected, got %d", len(events))
}
fields := []string{}
for _, change := range events[0].Changes {
fields = append(fields, change.Field)
}
if !strings.Contains(strings.Join(fields, ","), audit.FieldTitle) || !strings.Contains(strings.Join(fields, ","), audit.FieldSlug) || events[0].Params[audit.ParamLang] != "en" {
t.Errorf("title, slug and lang expected: %v %v", fields, events[0].Params)
}
})
}
func assertPositiveRevision(t *testing.T, fields map[string]json.RawMessage) {
t.Helper()
value, err := strconv.Atoi(string(fields["revision"]))
if err != nil || value < 1 {
t.Errorf("revision must be a positive integer: %s", fields["revision"])
}
}
+77
View File
@@ -0,0 +1,77 @@
package test
import (
"RenewCMS/internal/domain/article"
"testing"
)
func TestArticleImpactOfImageIdentity(t *testing.T) {
one, sameOne, two := uint32(1), uint32(1), uint32(2)
cases := []struct {
name string
before, after *uint32
edited bool
}{
{"both absent", nil, nil, false},
{"added", nil, &one, true},
{"removed", &one, nil, true},
{"same id", &one, &sameOne, false},
{"other id", &one, &two, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, social := range []bool{false, true} {
before, after := article.Article{}, article.Article{}
if social {
before.SocialImageID, after.SocialImageID = c.before, c.after
} else {
before.CoverImageID, after.CoverImageID = c.before, c.after
}
impact := article.ImpactOf(before, after, false)
if impact.Edited != c.edited || impact.Content {
t.Errorf("social=%v: got %+v, want edited=%v content=false", social, impact, c.edited)
}
}
})
}
}
func TestArticleImpactOfPerField(t *testing.T) {
cases := []struct {
name string
change func(a *article.Article)
authors bool
edited bool
content bool
}{
{"nothing", func(a *article.Article) {}, false, false, false},
{"title", func(a *article.Article) { a.Title = "other" }, false, true, true},
{"body", func(a *article.Article) { a.Body = "other" }, false, true, true},
{"excerpt", func(a *article.Article) { a.Excerpt = "other" }, false, true, true},
{"slug", func(a *article.Article) { a.Slug = "other" }, false, true, true},
{"cover alt", func(a *article.Article) { a.CoverImageAlt = "other" }, false, true, true},
{"seo title", func(a *article.Article) { a.SEOTitle = "other" }, false, true, true},
{"seo description", func(a *article.Article) { a.SEODescription = "other" }, false, true, true},
{"social title", func(a *article.Article) { a.SocialTitle = "other" }, false, true, true},
{"social description", func(a *article.Article) { a.SocialDescription = "other" }, false, true, true},
{"authors", func(a *article.Article) {}, true, true, false},
{"site", func(a *article.Article) { a.SiteID = 9 }, false, true, false},
{"canonical url", func(a *article.Article) { a.CanonicalURL = "https://example.org/a" }, false, true, false},
{"noindex", func(a *article.Article) { a.Noindex = true }, false, true, false},
{"seo dismissed", func(a *article.Article) { a.SEODismissed = []string{"title"} }, false, true, false},
{"is online", func(a *article.Article) { a.IsOnline = true }, false, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
before := article.Article{}
after := before
c.change(&after)
impact := article.ImpactOf(before, after, c.authors)
if impact.Edited != c.edited || impact.Content != c.content {
t.Errorf("got %+v, want edited=%v content=%v", impact, c.edited, c.content)
}
})
}
}
@@ -78,19 +78,14 @@ func TestArticleLocalizationStorageHTTP(t *testing.T) {
}) })
} }
t.Run("Article write payloads cannot set lang or story_id", func(t *testing.T) { t.Run("PATCH ignores lang and story_id", func(t *testing.T) {
e, router := newCreatedByEnv(t) e, router := newCreatedByEnv(t)
e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false) e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false)
created := sendCreatedBy(router, http.MethodPost, "/v1/article/", `{"title":"Fresh draft title","body":"","lang":"en","story_id":99}`, asUser(e, createdByWriterID))
patched := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"excerpt":"Short","lang":"en","story_id":99}`, asUser(e, activityOwnerID)) patched := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"excerpt":"Short","lang":"en","story_id":99}`, asUser(e, activityOwnerID))
if created.Code != http.StatusCreated || patched.Code != http.StatusOK { if patched.Code != http.StatusOK {
t.Fatalf("statuses %d and %d: %s %s", created.Code, patched.Code, created.Body, patched.Body) t.Fatalf("status %d: %s", patched.Code, patched.Body)
}
createdFields := rawFields(decodeArticleFields(t, created.Body.Bytes()))
if createdFields["lang"] == `"en"` || createdFields["story_id"] == "99" {
t.Errorf("the create payload set lang or story_id: %v", createdFields)
} }
assertFields(t, rawFields(decodeArticleFields(t, patched.Body.Bytes())), "fr-CA", legacyArticleKeys) assertFields(t, rawFields(decodeArticleFields(t, patched.Body.Bytes())), "fr-CA", legacyArticleKeys)
if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory { if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory {
@@ -25,6 +25,7 @@ const (
languageSuggestionsMarker = "site_language_suggestions" languageSuggestionsMarker = "site_language_suggestions"
storyLanguageIndexName = "idx_articles_story_lang" storyLanguageIndexName = "idx_articles_story_lang"
siteSlugIndexName = "idx_articles_site_slug" siteSlugIndexName = "idx_articles_site_slug"
retiredIDsMarker = "article_retired_ids"
slugPerLanguageMarker = "article_slug_index_per_language" slugPerLanguageMarker = "article_slug_index_per_language"
injectedBackfillFailure = "injected backfill failure" injectedBackfillFailure = "injected backfill failure"
raceCreators = 8 raceCreators = 8
@@ -73,7 +74,7 @@ func regressToPreLocalizationSchema(t *testing.T, db *gorm.DB) {
"UPDATE articles SET story_id = 0, lang = ''", "UPDATE articles SET story_id = 0, lang = ''",
"DELETE FROM stories", "DELETE FROM stories",
"DELETE FROM site_languages", "DELETE FROM site_languages",
"DELETE FROM migration_markers WHERE name IN ('" + storiesBackfillMarker + "', '" + languageSuggestionsMarker + "', '" + slugPerLanguageMarker + "')", "DELETE FROM migration_markers WHERE name IN ('" + storiesBackfillMarker + "', '" + languageSuggestionsMarker + "', '" + slugPerLanguageMarker + "', '" + retiredIDsMarker + "')",
} }
for _, statement := range statements { for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil { if err := db.Exec(statement).Error; err != nil {
+3 -1
View File
@@ -27,7 +27,9 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+720
View File
@@ -0,0 +1,720 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"encoding/json"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func capabilitiesOf(t *testing.T, response storyResponse) []string {
t.Helper()
var capabilities []string
if err := json.Unmarshal(response.fields(t)["capabilities"], &capabilities); err != nil {
t.Fatalf("capabilities: %v in %s", err, response.text())
}
return capabilities
}
func translationsOf(t *testing.T, response storyResponse) []map[string]json.RawMessage {
t.Helper()
var translations []map[string]json.RawMessage
if err := json.Unmarshal(response.fields(t)["translations"], &translations); err != nil {
t.Fatalf("translations: %v in %s", err, response.text())
}
return translations
}
func languagesIn(translations []map[string]json.RawMessage) []string {
tags := []string{}
for _, entry := range translations {
tags = append(tags, strings.Trim(string(entry["lang"]), `"`))
}
return tags
}
func disableOnJournal(w *storyWorld, tag string) {
rows := w.rows.rows[storyJournal]
for index := range rows {
if rows[index].Tag == tag {
rows[index].Enabled = false
}
}
}
func TestArticleStoryLifecycleHTTP(t *testing.T) {
t.Run("Capabilities of the owner", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "")
if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, []string{"read", "edit", "publish", "delete", "create_translation"}) {
t.Errorf("capabilities %v", got)
}
})
t.Run("Capabilities per caller and version", func(t *testing.T) {
cases := []struct {
name string
user uint32
id string
online11 bool
want []string
}{
{"writer1 on its own draft", storyWriter1, "12", false, []string{"read", "edit", "publish", "delete", "create_translation"}},
{"writer1 on a readable sibling of another user", storyWriter1, "11", true, []string{"read", "create_translation"}},
{"reader", storyReader, "10", false, []string{"read"}},
{"update and read all", storyUpdater, "12", false, []string{"read", "edit"}},
{"publish and read all", storyPublish, "12", false, []string{"read", "publish"}},
{"anonymous", storyAnon, "10", false, []string{"read"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := newStoryWorld(t)
if c.online11 {
sibling := w.stored(11)
sibling.IsOnline = true
w.articles.articles[11] = sibling
}
response := w.send(c.user, http.MethodGet, "/v1/article/"+c.id, "")
if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, c.want) {
t.Errorf("capabilities %v, expected %v", got, c.want)
}
})
}
})
t.Run("Translations list readable versions only", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyWriter1, http.MethodGet, "/v1/article/10", "")
translations := translationsOf(t, response)
if got := languagesIn(translations); !reflect.DeepEqual(got, []string{"en", "ar", "fr"}) {
t.Errorf("translations %v", got)
}
for _, entry := range translations {
for _, key := range []string{"id", "lang", "title", "is_online", "language_enabled", "capabilities"} {
if _, present := entry[key]; !present {
t.Errorf("entry misses %q: %v", key, entry)
}
}
}
if strings.Contains(response.text(), "Kosten") || strings.Contains(response.text(), `"de"`) {
t.Errorf("the hidden version leaks: %s", response.text())
}
})
t.Run("Translations are ordered default language first, then by tag", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "")
if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) {
t.Errorf("translations %v", got)
}
})
t.Run("Disabled languages are marked", func(t *testing.T) {
w := newStoryWorld(t)
disableOnJournal(w, "fr")
response := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
for _, entry := range translationsOf(t, response) {
enabled := string(entry["language_enabled"])
if lang := string(entry["lang"]); (lang == `"fr"`) == (enabled == "true") {
t.Errorf("fr must be flagged disabled and still listed: %v", entry)
}
}
if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) {
t.Errorf("fr must still be listed: %v", got)
}
})
t.Run("A single-version article lists itself", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8})
response := w.send(storyOwner, http.MethodGet, "/v1/article/20", "")
translations := translationsOf(t, response)
if len(translations) != 1 || string(translations[0]["id"]) != "20" {
t.Errorf("translations %v", translations)
}
})
t.Run("Anonymous reads expose nothing about siblings", func(t *testing.T) {
w := newStoryWorld(t)
response := w.send(storyAnon, http.MethodGet, "/v1/article/10", "")
fields := response.fields(t)
if string(fields["translations"]) != "[]" || string(fields["capabilities"]) != `["read"]` || string(fields["source"]) != "null" {
t.Errorf("unexpected anonymous fields: %s", response.text())
}
expected := append([]string{"lang", "story_id", "revision", "capabilities", "translations", "source", "body_format"}, legacyArticleKeys...)
for key := range fields {
if !contains(expected, key) {
t.Errorf("unexpected key %q in the public shape", key)
}
}
})
t.Run("The source on the read", func(t *testing.T) {
w := newStoryWorld(t)
source := uint32(10)
w.put(article.Article{ID: 14, Title: "Copy", CreatedBy: storyRestrict, Slug: "copy", Lang: "en", StoryID: 14, SourceArticleID: &source, SourceRevision: 4})
w.put(article.Article{ID: 15, Title: "Empty", CreatedBy: storyWriter1, Slug: "empty", Lang: "en", StoryID: 15})
response := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
if string(response.fields(t)["source"]) != `{"article_id":10,"revision":4}` {
t.Errorf("source %s", response.fields(t)["source"])
}
if string(w.send(storyOwner, http.MethodGet, "/v1/article/15", "").fields(t)["source"]) != "null" {
t.Errorf("an empty version has no source")
}
draft := w.stored(10)
draft.IsOnline = false
w.articles.articles[10] = draft
if string(w.send(storyReader, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) == "null" {
t.Errorf("the reader reads everything and sees the source")
}
if string(w.send(storyRestrict, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" {
t.Errorf("an unreadable source is null")
}
delete(w.articles.articles, 10)
if string(w.send(storyOwner, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" {
t.Errorf("a deleted source is null")
}
})
t.Run("Publishing respects the language configuration", func(t *testing.T) {
cases := []struct {
name string
id uint32
status int
message string
online bool
}{
{"fr enabled", 12, 200, "", true},
{"es disabled, offline version", 15, 422, apperr.LanguageDisabledErrorMessage, false},
{"es disabled, already online", 16, 200, "", true},
{"unassigned language", 30, 200, "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15})
w.put(article.Article{ID: 16, Title: "Online es", CreatedBy: 1, Slug: "es-online", Lang: "es", StoryID: 16, IsOnline: true})
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30})
response := w.send(storyPublish, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(c.id))+"/publish", "")
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
}
if w.stored(c.id).IsOnline != c.online {
t.Errorf("is_online = %v, expected %v", w.stored(c.id).IsOnline, c.online)
}
})
}
})
t.Run("Unpublish and edit stay available in a disabled language", func(t *testing.T) {
w := newStoryWorld(t)
fr := w.stored(12)
fr.IsOnline = true
w.articles.articles[12] = fr
disableOnJournal(w, "fr")
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "")
})
t.Run("Publishing one version leaves the others alone", func(t *testing.T) {
w := newStoryWorld(t)
before := w.snapshot()
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
for _, id := range []uint32{10, 11, 13} {
if !articlesEqual(before[id], w.stored(id)) {
t.Errorf("article %d changed", id)
}
}
})
t.Run("Re-enabling a language allows publishing again", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15})
w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 422, apperr.LanguageDisabledErrorMessage)
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar", "es"}, nil)
w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 200, "")
})
t.Run("Existing publish errors are unchanged", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyOwner, http.MethodPatch, "/v1/article/9999/publish", "").expect(t, 404, apperr.ArticleNotFoundErrorMessage)
w.send(storyOwner, http.MethodPatch, "/v1/article/abc/publish", "").expect(t, 400, apperr.IdUint32ErrorMessage)
w.send(storyAnon, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 401, "")
w.send(storyReader, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 403, apperr.MissingPermissionErrorMessage)
})
t.Run("Deleting one version keeps the story", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
for _, id := range []uint32{10, 11, 13} {
if _, present := w.articles.articles[id]; !present {
t.Errorf("article %d must remain", id)
}
}
listing := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
if got := languagesIn(translationsOf(t, listing)); !reflect.DeepEqual(got, []string{"en", "ar", "de"}) {
t.Errorf("translations %v", got)
}
})
t.Run("A deleted language can be created again", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
response := w.send(storyOwner, http.MethodPost, "/v1/article/10/translations", `{"lang":"fr","title":"Couts","copy":false}`)
response.expect(t, 201, "")
})
t.Run("Deleting the last version removes the story", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8})
w.send(storyOwner, http.MethodDelete, "/v1/article/20", "").expect(t, 204, "")
if versions, _ := w.articles.ListByStory(8); len(versions) != 0 {
t.Errorf("story 8 must be gone")
}
w.send(storyOwner, http.MethodPost, "/v1/article/20/translations", `{"lang":"fr","title":"Couts","copy":false}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage)
})
t.Run("Deleting the last of several versions", func(t *testing.T) {
w := newStoryWorld(t)
for position, id := range []string{"10", "11", "12", "13"} {
w.send(storyOwner, http.MethodDelete, "/v1/article/"+id, "").expect(t, 204, "")
versions, _ := w.articles.ListByStory(storyStory)
if (position < 3) != (len(versions) > 0) {
t.Errorf("after deleting %s, %d versions remain", id, len(versions))
}
}
})
t.Run("Delete is checked per version", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyWriter1, http.MethodDelete, "/v1/article/11", "").expect(t, 403, apperr.MissingPermissionErrorMessage)
if _, present := w.articles.articles[11]; !present {
t.Errorf("article 11 must remain")
}
})
t.Run("A story with a hidden version", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 80, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "en", StoryID: 80})
w.put(article.Article{ID: 81, Title: "Hidden", CreatedBy: 2, Slug: "hidden", Lang: "de", StoryID: 80})
response := w.send(storyWriter1, http.MethodDelete, "/v1/article/80", "")
response.expect(t, 204, "")
if versions, _ := w.articles.ListByStory(80); len(versions) != 1 || response.text() != "" {
t.Errorf("the story must remain and the response mention nothing: %d %q", len(versions), response.text())
}
})
moveBody := `{"site":"atelier"}`
t.Run("Move a story", func(t *testing.T) {
w := newStoryWorld(t)
before := w.snapshot()
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 200, "")
if response.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` {
t.Errorf("unexpected body %s", response.text())
}
for id, previous := range before {
moved := w.stored(id)
if moved.SiteID != 2 || moved.EditRevision != previous.EditRevision+1 || moved.ContentRevision != previous.ContentRevision {
t.Errorf("article %d: %+v", id, moved)
}
previous.SiteID, previous.EditRevision = moved.SiteID, moved.EditRevision
if !articlesEqual(previous, moved) {
t.Errorf("article %d changed beyond its site: %+v", id, moved)
}
}
})
t.Run("A move requires every language to be enabled at the destination", func(t *testing.T) {
w := newStoryWorld(t)
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
before := w.snapshot()
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
if response.header(apperr.MissingLanguagesHeader) != "ar" {
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
}
w.expectUnchanged(before)
})
t.Run("A language disabled at the destination counts as missing", func(t *testing.T) {
w := newStoryWorld(t)
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "ar"}, []string{"de"})
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
if !strings.Contains(response.header(apperr.MissingLanguagesHeader), "de") {
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
}
})
t.Run("A language disabled at the source still needs the destination", func(t *testing.T) {
w := newStoryWorld(t)
disableOnJournal(w, "fr")
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "")
w = newStoryWorld(t)
disableOnJournal(w, "fr")
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "de", "ar"}, nil)
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
if response.header(apperr.MissingLanguagesHeader) != "fr" {
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
}
})
t.Run("Address conflict at the destination", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 11, Title: "Kosten", CreatedBy: storyPartial, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * 60e9)})
w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyPartial, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(60e9)})
hidden := w.stored(13)
hidden.IsOnline = false
w.articles.articles[13] = hidden
w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 91})
w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier ar", CreatedBy: 1, Slug: "takalif", Lang: "ar", StoryID: 92})
before := w.snapshot()
response := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 409, apperr.SlugConflictErrorMessage)
if response.header(apperr.ConflictingArticlesHeader) != "12" {
t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader))
}
w.expectUnchanged(before)
})
t.Run("Every conflict of the story is checked", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier en", CreatedBy: 1, Slug: "renovation-costs", Lang: "en", StoryID: 91})
w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 92})
before := w.snapshot()
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 409, apperr.SlugConflictErrorMessage)
if response.header(apperr.ConflictingArticlesHeader) != "10,12" {
t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader))
}
w.expectUnchanged(before)
})
t.Run("A move needs update permission on every version, readable or not", func(t *testing.T) {
cases := []struct {
name string
user uint32
offline bool
status int
message string
readable bool
}{
{"an anonymous client", storyAnon, false, 401, "", true},
{"writer1", storyWriter1, false, 403, apperr.MissingPermissionErrorMessage, true},
{"a user without content.update", storyReader, false, 403, apperr.MissingPermissionErrorMessage, true},
{"a user who can read no version", storyPartial, true, 404, "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := newStoryWorld(t)
if c.offline {
for _, id := range []uint32{10, 13} {
draft := w.stored(id)
draft.IsOnline = false
w.articles.articles[id] = draft
}
}
before := w.snapshot()
response := w.send(c.user, http.MethodPost, "/v1/story/5/move", moveBody)
if c.readable {
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
}
} else {
response.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
}
w.expectUnchanged(before)
})
}
})
t.Run("Hidden versions never leak through the move", func(t *testing.T) {
w := newStoryWorld(t)
hidden := w.stored(13)
hidden.IsOnline = false
w.articles.articles[13] = hidden
response := w.send(storyWriter1, http.MethodPost, "/v1/story/5/move", moveBody)
response.expect(t, 403, apperr.MissingPermissionErrorMessage)
if strings.Contains(response.text(), "11") || strings.Contains(response.text(), "13") || strings.Contains(response.Header().Get(apperr.MissingPermissionHeader), "1") {
t.Errorf("the response names a hidden version")
}
w = newStoryWorld(t)
hidden = w.stored(13)
hidden.IsOnline = false
w.articles.articles[13] = hidden
for _, id := range []uint32{10, 11, 12} {
readable := w.stored(id)
readable.CreatedBy = storyPartial
w.articles.articles[id] = readable
}
success := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody)
success.expect(t, 200, "")
if success.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11]}` {
t.Errorf("article_ids must list readable versions only: %s", success.text())
}
})
t.Run("Move validation", func(t *testing.T) {
cases := []struct {
name string
path string
body string
status int
message string
}{
{"unknown site", "/v1/story/5/move", `{"site":"nowhere"}`, 404, apperr.SiteNotFoundErrorMessage},
{"empty body", "/v1/story/5/move", `{}`, 400, apperr.BodyErrorMessage},
{"malformed JSON", "/v1/story/5/move", `{"site":`, 400, apperr.BodyErrorMessage},
{"unknown story", "/v1/story/9999/move", moveBody, 404, apperr.ArticleNotFoundErrorMessage},
{"bad story id", "/v1/story/abc/move", moveBody, 400, apperr.IdUint32ErrorMessage},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyOwner, http.MethodPost, c.path, c.body).expect(t, c.status, c.message)
})
}
})
t.Run("Moving to the current site", func(t *testing.T) {
w := newStoryWorld(t)
before := w.snapshot()
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"journal"}`)
response.expect(t, 200, "")
w.expectUnchanged(before)
if len(w.recorder.events) != 0 {
t.Errorf("no activity event expected, got %d", len(w.recorder.events))
}
})
t.Run("The move is atomic and revalidated inside the transaction", func(t *testing.T) {
w := newStoryWorld(t)
w.articles.beforeMove = func() {
w.put(article.Article{ID: 95, SiteID: storyAtelier, Title: "Late", CreatedBy: 1, Slug: "kosten", Lang: "de", StoryID: 95})
}
before := w.snapshot()
delete(before, 95)
late := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
late.expect(t, 409, apperr.SlugConflictErrorMessage)
delete(w.articles.articles, 95)
w.expectUnchanged(before)
w.articles.moveFailsAt = 3
failed := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
if failed.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", failed.Code)
}
w.expectUnchanged(before)
})
t.Run("PATCH site_id cannot split a story", func(t *testing.T) {
w := newStoryWorld(t)
before := w.snapshot()
w.send(storyOwner, http.MethodPatch, "/v1/article/10", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage)
w.expectUnchanged(before)
})
t.Run("PATCH site_id counts readable versions only", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 60, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 60})
w.put(article.Article{ID: 61, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden", Lang: "de", StoryID: 60})
w.put(article.Article{ID: 62, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "fr", StoryID: 62})
w.put(article.Article{ID: 63, Title: "Not mine", CreatedBy: storyOwner, Slug: "not-mine", Lang: "de", StoryID: 62})
w.put(article.Article{ID: 64, Title: "First", CreatedBy: storyPartial, Slug: "first", Lang: "fr", StoryID: 64})
w.put(article.Article{ID: 65, Title: "Second", CreatedBy: storyPartial, Slug: "second", Lang: "de", StoryID: 64})
w.put(article.Article{ID: 66, Title: "Third hidden", CreatedBy: storyOwner, Slug: "third-hidden", Lang: "en", StoryID: 64})
denied := w.send(storyWriter1, http.MethodPatch, "/v1/article/62", `{"site_id":2}`)
denied.expect(t, 403, apperr.MissingPermissionErrorMessage)
if denied.header("X-Missing-Permission") != "content.update" || strings.Contains(denied.text(), "hidden") {
t.Errorf("the refusal must only name the permission: %v %q", denied.Header(), denied.text())
}
if w.stored(62).SiteID != storyJournal || w.stored(63).SiteID != storyJournal {
t.Errorf("a refused move must change nothing")
}
w.send(storyPartial, http.MethodPatch, "/v1/article/64", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage)
if w.stored(64).SiteID != storyJournal {
t.Errorf("a refused move must change nothing")
}
w.send(storyPartial, http.MethodPatch, "/v1/article/60", `{"site_id":2}`).expect(t, 200, "")
if w.stored(60).SiteID != 2 || w.stored(61).SiteID != 2 {
t.Errorf("every version of the story moves together: %d %d", w.stored(60).SiteID, w.stored(61).SiteID)
}
})
t.Run("PATCH site_id with a hidden version applies the story move rules", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70})
w.put(article.Article{ID: 72, SiteID: storyAtelier, Title: "Taken", CreatedBy: 1, Slug: "hidden-de", Lang: "de", StoryID: 72})
before := w.snapshot()
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2}`)
response.expect(t, 409, apperr.SlugConflictErrorMessage)
if strings.Contains(response.text(), "hidden") || response.header(apperr.ConflictingArticlesHeader) != "" {
t.Errorf("the refusal must not name hidden versions: %v %q", response.Header(), response.text())
}
w.expectUnchanged(before)
})
t.Run("PATCH site_id with a hidden version raises revision by exactly 1", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3, ContentRevision: 4})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5, ContentRevision: 6})
w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":3}`).expect(t, 200, "")
if edit, content := revisionsOf(w, 70); w.stored(70).SiteID != 2 || edit != 4 || content != 4 {
t.Errorf("site %d revision %d content_revision %d, expected 2, 4 and 4", w.stored(70).SiteID, edit, content)
}
if edit, content := revisionsOf(w, 71); w.stored(71).SiteID != 2 || edit != 6 || content != 6 {
t.Errorf("hidden version: site %d revision %d content_revision %d, expected 2, 6 and 6", w.stored(71).SiteID, edit, content)
}
})
t.Run("PATCH site_id with a stale if_revision moves nobody", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5})
before := w.snapshot()
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":2}`)
if response.Code != 409 {
t.Errorf("expected 409, got %d %s", response.Code, response.text())
}
w.expectUnchanged(before)
})
t.Run("PATCH site_id on a single-version story applies the move rules", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "fr", StoryID: 8})
w.put(article.Article{ID: 21, Title: "Alone es", CreatedBy: storyWriter1, Slug: "alone-es", Lang: "es", StoryID: 21})
w.put(article.Article{ID: 22, Title: "Alone unassigned", CreatedBy: storyWriter1, Slug: "alone-unassigned", Lang: "", StoryID: 22})
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`).expect(t, 200, "")
w.send(storyOwner, http.MethodPatch, "/v1/article/21", `{"site_id":2}`).expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
w.send(storyOwner, http.MethodPatch, "/v1/article/22", `{"site_id":2}`).expect(t, 200, "")
if w.stored(20).SiteID != 2 || w.stored(21).SiteID != 1 || w.stored(22).SiteID != 2 {
t.Errorf("unexpected sites %d %d %d", w.stored(20).SiteID, w.stored(21).SiteID, w.stored(22).SiteID)
}
})
t.Run("Activity events carry the language", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30})
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "")
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
w.send(storyOwner, http.MethodPatch, "/v1/article/30/publish", "").expect(t, 200, "")
for _, action := range []string{audit.ActionArticlePublish, audit.ActionArticleUnpublish, audit.ActionArticleEdit, audit.ActionArticleDelete} {
events := w.eventsOf(action)
if len(events) == 0 || events[0].Params[audit.ParamLang] != "fr" {
t.Errorf("%s must carry lang fr: %+v", action, events)
}
}
if publishes := w.eventsOf(audit.ActionArticlePublish); len(publishes) != 2 {
t.Fatalf("expected two publish events, got %d", len(publishes))
} else if _, present := publishes[1].Params[audit.ParamLang]; present {
t.Errorf("an unassigned article carries no lang")
}
w = newStoryWorld(t)
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "")
moves := w.eventsOf(audit.ActionArticleEdit)
if len(moves) != 4 {
t.Fatalf("one edit event per moved version expected, got %d", len(moves))
}
for _, event := range moves {
if event.Params[audit.ParamLang] == nil || len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldSite || *event.Changes[0].After != "Atelier" {
t.Errorf("unexpected move event %+v", event)
}
}
w = newStoryWorld(t)
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
refusals := w.eventsOf(audit.ActionArticleEdit)
if len(refusals) != 1 || refusals[0].Outcome != audit.OutcomeFailed || refusals[0].FailureReason == nil {
t.Fatalf("one failed event expected: %+v", refusals)
}
})
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
@@ -0,0 +1,422 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"gorm.io/gorm"
)
const (
writeCallbackName = "test:fail-third-move"
writeInjectedFailure = "injected move failure"
writeRaceWriters = 6
concurrentEditCallbackName = "test:concurrent-title-edit"
)
type storyDatabase struct {
db *gorm.DB
articles *persistence.ArticleRepository
journal entity.Site
atelier entity.Site
}
func newStoryDatabase(t *testing.T, open func(*testing.T) *gorm.DB) *storyDatabase {
t.Helper()
db := open(t)
sites := persistence.NewSiteRepository(db)
journal, atelier := mustCreateSite(t, sites, "Journal", "journal", false), mustCreateSite(t, sites, "Atelier", "atelier", false)
for _, siteID := range []uint32{journal.ID, atelier.ID} {
for _, tag := range []string{"en", "fr", "de"} {
row := entity.SiteLanguage{SiteID: siteID, Lang: tag, IsDefault: tag == "en", Enabled: true, Confirmed: tag == "en"}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
}
return &storyDatabase{db: db, articles: persistence.NewArticleRepository(db), journal: entity.Site{ID: journal.ID, Key: "journal"}, atelier: entity.Site{ID: atelier.ID, Key: "atelier"}}
}
func (s *storyDatabase) add(t *testing.T, siteID uint32, storyID uint32, lang string, slug string) domainArticle.Article {
t.Helper()
created, err := s.articles.Create(domainArticle.Article{Title: "Article " + slug, Body: "Body", SiteID: siteID, StoryID: storyID, Lang: lang, Slug: slug, SEODismissed: []string{}, CreatedBy: 1})
if err != nil {
t.Fatalf("create %s/%s: %v", lang, slug, err)
}
return created
}
func (s *storyDatabase) stories(t *testing.T) int64 {
t.Helper()
var count int64
if err := s.db.Model(&entity.Story{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
return count
}
func (s *storyDatabase) translations() *useCases.CreateTranslationUseCase {
return useCases.NewCreateTranslationUseCase(persistence.NewTransactor(s.db), s.articles, persistence.NewLocalizationRepository(s.db), &fakeBodyRenderer{}, discardRecorder)
}
func editTitleBeforeNextWrite(t *testing.T, s *storyDatabase, id uint32, title string) {
t.Helper()
armed := true
err := s.db.Callback().Update().Before("gorm:update").Register(concurrentEditCallbackName, func(tx *gorm.DB) {
if !armed || tx.Statement.Table != "articles" {
return
}
armed = false
if _, err := s.articles.Update(id, domainArticle.Update{Title: &title}); err != nil {
_ = tx.AddError(err)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.db.Callback().Update().Remove(concurrentEditCallbackName) })
}
func yes() *bool {
value := true
return &value
}
func runArticleStoryWritePath(t *testing.T, open func(*testing.T) *gorm.DB) {
t.Run("A second version in the same language is refused, another language is stored", func(t *testing.T) {
s := newStoryDatabase(t, open)
first := s.add(t, s.journal.ID, 0, "en", "alpha")
_, duplicate := s.articles.Create(domainArticle.Article{Title: "Beta", SiteID: s.journal.ID, StoryID: first.StoryID, Lang: "en", Slug: "beta", SEODismissed: []string{}})
other := s.add(t, s.journal.ID, first.StoryID, "fr", "alpha-fr")
if !errors.Is(duplicate, domainArticle.ErrLanguageTaken) || other.StoryID != first.StoryID {
t.Errorf("expected ErrLanguageTaken then a stored fr version, got %v and %+v", duplicate, other)
}
})
t.Run("A deleted language can be created again and still counts as in use", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
for round := 0; round < 3; round++ {
french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
if err := s.articles.Delete(french.ID); err != nil {
t.Fatalf("round %d: delete: %v", round, err)
}
}
counts, err := persistence.NewLocalizationRepository(s.db).VersionCounts(s.journal.ID)
if err != nil || counts["fr"].Trashed != 3 || counts["fr"].Total != 0 {
t.Errorf("deleted fr versions must still count as in use: %+v %v", counts["fr"], err)
}
if _, err := s.articles.Create(domainArticle.Article{Title: "Again", SiteID: s.journal.ID, StoryID: english.StoryID, Lang: "fr", Slug: "alpha-fr", SEODismissed: []string{}}); err != nil {
t.Errorf("fr can be created again: %v", err)
}
})
t.Run("Deleting one version keeps the story, deleting the last removes it", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
alone := s.add(t, s.journal.ID, 0, "en", "alone")
stories := s.stories(t)
if err := s.articles.Delete(french.ID); err != nil || s.stories(t) != stories {
t.Fatalf("deleting one of two versions keeps the story: %v %d", err, s.stories(t))
}
if err := s.articles.Delete(english.ID); err != nil || s.stories(t) != stories-1 {
t.Fatalf("deleting the last version removes the story: %v %d", err, s.stories(t))
}
if err := s.articles.Delete(alone.ID); err != nil || s.stories(t) != stories-2 {
t.Fatalf("deleting a single version removes its story: %v %d", err, s.stories(t))
}
if _, err := s.articles.Create(domainArticle.Article{Title: "Orphan", SiteID: s.journal.ID, StoryID: english.StoryID, Lang: "de", Slug: "orphan", SEODismissed: []string{}}); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("creating in a removed story is refused: %v", err)
}
})
t.Run("The free-slug retry gives the next suffixed slug", func(t *testing.T) {
s := newStoryDatabase(t, open)
first := s.add(t, s.journal.ID, 0, "en", "alpha")
second := s.add(t, s.journal.ID, 0, "en", "other")
caller := ownerCaller(1)
one, err := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: first.ID, Lang: "fr", Title: "Meme titre", Copy: yes(), Caller: caller})
two, errTwo := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: second.ID, Lang: "fr", Title: "Meme titre", Copy: yes(), Caller: caller})
if err != nil || errTwo != nil || one.Slug != "meme-titre" || two.Slug != "meme-titre-2" {
t.Errorf("unexpected slugs %q %q (%v, %v)", one.Slug, two.Slug, err, errTwo)
}
if one.SourceArticleID == nil || *one.SourceArticleID != first.ID || one.SourceRevision != 1 {
t.Errorf("the source must be captured with the copy: %+v", one)
}
})
t.Run("Concurrent translation creates keep exactly one version", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
var created atomic.Int32
var refused atomic.Int32
jobs := []func(){}
for range writeRaceWriters {
jobs = append(jobs, func() {
_, err := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: english.ID, Lang: "fr", Title: "Couts", Copy: yes(), Caller: ownerCaller(1)})
var existing *domainArticle.LanguageExistsError
switch {
case err == nil:
created.Add(1)
case errors.Is(err, domainArticle.ErrLanguageUnavailable), errors.As(err, &existing):
refused.Add(1)
default:
t.Errorf("unexpected error: %v", err)
}
})
}
runTogether(jobs...)
versions, err := s.articles.ListByStory(english.StoryID)
french := 0
for _, version := range versions {
if version.Lang == "fr" {
french++
}
}
if err != nil || created.Load() != 1 || refused.Load() != writeRaceWriters-1 || french != 1 {
t.Errorf("created %d, refused %d, fr versions %d (%v)", created.Load(), refused.Load(), french, err)
}
})
t.Run("The compare and the write of an edit are one atomic step", func(t *testing.T) {
s := newStoryDatabase(t, open)
created := s.add(t, s.journal.ID, 0, "en", "alpha")
var succeeded atomic.Int32
var conflicted atomic.Int32
jobs := []func(){}
for range writeRaceWriters {
jobs = append(jobs, func() {
body := "edited"
revision := created.EditRevision
_, err := s.articles.Update(created.ID, domainArticle.Update{Body: &body, IfRevision: &revision})
var conflict *domainArticle.RevisionConflictError
switch {
case err == nil:
succeeded.Add(1)
case errors.As(err, &conflict):
conflicted.Add(1)
default:
t.Errorf("unexpected error: %v", err)
}
})
}
runTogether(jobs...)
stored := storedArticle(t, s.db, created.ID)
if succeeded.Load() != 1 || conflicted.Load() != writeRaceWriters-1 || stored.EditRevision != created.EditRevision+1 || stored.ContentRevision != created.ContentRevision+1 {
t.Errorf("succeeded %d, conflicted %d, stored %+v", succeeded.Load(), conflicted.Load(), stored)
}
})
t.Run("A story moves as a whole with a revision bump per version", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de")
mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder)
result, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)})
if err != nil || len(result.ArticleIDs) != 3 || result.Site.ID != s.atelier.ID {
t.Fatalf("move: %+v %v", result, err)
}
versions, _ := s.articles.ListByStory(english.StoryID)
for _, version := range versions {
if version.SiteID != s.atelier.ID || version.EditRevision != 2 || version.ContentRevision != 1 {
t.Errorf("version %d: %+v", version.ID, version)
}
}
})
t.Run("A unique address violation at the destination rolls the whole move back", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de")
s.add(t, s.atelier.ID, 0, "de", "alpha-de")
mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder)
_, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)})
var conflict *domainArticle.SlugConflictError
if !errors.As(err, &conflict) {
t.Fatalf("expected a slug conflict, got %v", err)
}
versions, _ := s.articles.ListByStory(english.StoryID)
for _, version := range versions {
if version.SiteID != s.journal.ID || version.EditRevision != 1 {
t.Errorf("version %d must not move: %+v", version.ID, version)
}
}
})
t.Run("A write failing on the third version leaves every version unchanged", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de")
updates := 0
err := s.db.Callback().Update().Before("gorm:update").Register(writeCallbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "articles" {
return
}
updates++
if updates == 3 {
_ = tx.AddError(errors.New(writeInjectedFailure))
}
})
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.db.Callback().Update().Remove(writeCallbackName) }()
mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder)
_, failure := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)})
if failure == nil {
t.Fatal("the injected failure must abort the move")
}
versions, _ := s.articles.ListByStory(english.StoryID)
for _, version := range versions {
if version.SiteID != s.journal.ID || version.EditRevision != 1 {
t.Errorf("version %d must be rolled back: %+v", version.ID, version)
}
}
})
t.Run("A publish does not clobber a concurrent title edit", func(t *testing.T) {
s := newStoryDatabase(t, open)
created := s.add(t, s.journal.ID, 0, "en", "alpha")
editTitleBeforeNextWrite(t, s, created.ID, "Edited while publishing")
published, err := s.articles.UpdateIsOnline(created.ID, true)
stored := storedArticle(t, s.db, created.ID)
if err != nil || !published.IsOnline || !stored.IsOnline || stored.Title != "Edited while publishing" {
t.Errorf("publish %+v %v, stored %+v", published, err, stored)
}
if stored.EditRevision != created.EditRevision+1 || stored.ContentRevision != created.ContentRevision+1 {
t.Errorf("only the edit raises the revisions: %+v", stored)
}
})
t.Run("A body rewrite does not clobber a concurrent title edit", func(t *testing.T) {
s := newStoryDatabase(t, open)
created := s.add(t, s.journal.ID, 0, "en", "alpha")
editTitleBeforeNextWrite(t, s, created.ID, "Edited while rewriting")
rewritten, err := s.articles.UpdateBody(created.ID, "Rewritten body")
stored := storedArticle(t, s.db, created.ID)
if err != nil || rewritten.Body != "Rewritten body" || stored.Body != "Rewritten body" || stored.Title != "Edited while rewriting" {
t.Errorf("rewrite %+v %v, stored %+v", rewritten, err, stored)
}
})
t.Run("A publish of a missing article is not found", func(t *testing.T) {
s := newStoryDatabase(t, open)
if _, err := s.articles.UpdateIsOnline(9999, true); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected not found, got %v", err)
}
})
t.Run("Migration gives existing rows revision 1 and retires soft-deleted rows", func(t *testing.T) {
db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2)
migrateStorage(t, db)
for _, row := range storedArticles(t, db) {
if row.EditRevision != 1 || row.SourceArticleID != nil || (row.ID == 2) != (row.RetiredID == row.ID) {
t.Errorf("article %d: %+v", row.ID, row)
}
}
})
t.Run("The retired-id backfill runs once per database", func(t *testing.T) {
db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2)
migrateStorage(t, db)
if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", 2).UpdateColumn("retired_id", 0).Error; err != nil {
t.Fatal(err)
}
migrateStorage(t, db)
if retired := storedArticle(t, db, 2).RetiredID; retired != 0 {
t.Errorf("the second start must not run the backfill again, retired_id %d", retired)
}
})
t.Run("Duplicate versions of a language stop the unique index with a descriptive error", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
if err := s.db.Migrator().DropIndex(&entity.Article{}, "idx_articles_story_lang"); err != nil {
t.Fatal(err)
}
if err := s.db.Exec("DELETE FROM migration_markers WHERE name = 'article_story_language_retired'").Error; err != nil {
t.Fatal(err)
}
duplicate := entity.Article{StoryID: english.StoryID, Title: "Duplicate", SiteID: s.journal.ID, Lang: "en", Slug: "alpha-duplicate"}
if err := s.db.Create(&duplicate).Error; err != nil {
t.Fatal(err)
}
err := database.Migrate(s.db, migrationPublicURL)
if err == nil || !strings.Contains(err.Error(), "cannot create the unique index") || !strings.Contains(err.Error(), "en") {
t.Errorf("expected a descriptive refusal, got %v", err)
}
})
t.Run("A language row stays in use while only a deleted version exists", func(t *testing.T) {
s := newStoryDatabase(t, open)
english := s.add(t, s.journal.ID, 0, "en", "alpha")
french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
if err := s.articles.Delete(french.ID); err != nil {
t.Fatal(err)
}
repository := persistence.NewLocalizationRepository(s.db)
rows, _ := repository.Rows(s.journal.ID)
counts, _ := repository.VersionCounts(s.journal.ID)
_, err := localization.Plan(rows, counts, localization.Change{Default: "en", Languages: []localization.Entry{{Tag: "en", Enabled: true}, {Tag: "de", Enabled: true}}})
if !errors.Is(err, localization.ErrLanguageInUse) {
t.Errorf("removing fr while a deleted fr version exists must stay refused, got %v", err)
}
})
}
func TestArticleStoryWritePathSQLite(t *testing.T) {
runArticleStoryWritePath(t, openContractSQLite)
}
func TestArticleStoryWritePathMySQL(t *testing.T) {
runArticleStoryWritePath(t, openContractMySQL)
}
func TestArticleStoryWritePathPostgres(t *testing.T) {
runArticleStoryWritePath(t, openContractPostgres)
}
+106
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/domain/language" "RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"maps"
"slices" "slices"
"sort" "sort"
"strings" "strings"
@@ -30,6 +31,12 @@ type mockArticleRepo struct {
createAlwaysRaces bool createAlwaysRaces bool
afterFindByBody func() afterFindByBody func()
updateBodyIDs []uint32 updateBodyIDs []uint32
moveFailsAt int
beforeCreate func()
beforeUpdate func()
beforeMove func()
onGet func(id uint32) error
reverseStory bool
} }
func newMockArticleRepo() *mockArticleRepo { func newMockArticleRepo() *mockArticleRepo {
@@ -210,6 +217,11 @@ func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
if err := m.fail.trigger("Get"); err != nil { if err := m.fail.trigger("Get"); err != nil {
return article.Article{}, err return article.Article{}, err
} }
if m.onGet != nil {
if err := m.onGet(id); err != nil {
return article.Article{}, err
}
}
a, ok := m.articles[id] a, ok := m.articles[id]
if !ok { if !ok {
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
@@ -330,6 +342,10 @@ func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32)
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) { func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
m.createCalled = true m.createCalled = true
if hook := m.beforeCreate; hook != nil {
m.beforeCreate = nil
hook()
}
if err := m.fail.trigger("Create"); err != nil { if err := m.fail.trigger("Create"); err != nil {
return article.Article{}, err return article.Article{}, err
} }
@@ -343,6 +359,16 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, 0) { if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, 0) {
return article.Article{}, article.ErrSlugTaken return article.Article{}, article.ErrSlugTaken
} }
if a.StoryID != 0 {
if !m.storyHasVersions(a.StoryID) {
return article.Article{}, article.ErrArticleNotFound
}
if m.storyHasLanguage(a.StoryID, a.Lang) {
return article.Article{}, article.ErrLanguageTaken
}
}
a.ContentRevision = 1
a.EditRevision = 1
m.nextID++ m.nextID++
a.ID = m.nextID a.ID = m.nextID
m.articles[a.ID] = a m.articles[a.ID] = a
@@ -373,6 +399,70 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
return m.withAuthors(a), nil return m.withAuthors(a), nil
} }
func (m *mockArticleRepo) storyHasVersions(storyID uint32) bool {
for _, stored := range m.articles {
if stored.StoryID == storyID {
return true
}
}
return false
}
func (m *mockArticleRepo) storyHasLanguage(storyID uint32, lang string) bool {
for _, stored := range m.articles {
if stored.StoryID == storyID && stored.Lang == lang {
return true
}
}
return false
}
func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error) {
if err := m.fail.trigger("ListByStory"); err != nil {
return nil, err
}
versions := []article.Article{}
for _, id := range m.sortedIds() {
if stored := m.articles[id]; stored.StoryID == storyID {
versions = append(versions, m.withAuthors(stored))
}
}
if m.reverseStory {
slices.Reverse(versions)
}
return versions, nil
}
func (m *mockArticleRepo) MoveToSite(ids []uint32, siteID uint32) error {
if err := m.fail.trigger("MoveToSite"); err != nil {
return err
}
if hook := m.beforeMove; hook != nil {
m.beforeMove = nil
hook()
}
snapshot := maps.Clone(m.articles)
for position, id := range ids {
moving, ok := m.articles[id]
if !ok {
m.articles = snapshot
return article.ErrArticleNotFound
}
if m.moveFailsAt == position+1 {
m.articles = snapshot
return errRepositoryFailure
}
if m.slugUsedOn(siteID, moving.Lang, moving.Slug, id) {
m.articles = snapshot
return article.ErrSlugTaken
}
moving.SiteID = siteID
moving.EditRevision++
m.articles[id] = moving
}
return nil
}
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) { func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
if err := m.fail.trigger("Update"); err != nil { if err := m.fail.trigger("Update"); err != nil {
return article.Article{}, err return article.Article{}, err
@@ -381,6 +471,18 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
if !ok { if !ok {
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
} }
if hook := m.beforeUpdate; hook != nil {
m.beforeUpdate = nil
hook()
a = m.articles[id]
}
if update.IfRevision != nil && a.EditRevision != *update.IfRevision {
return article.Article{}, &article.RevisionConflictError{Current: a.EditRevision}
}
before := a
if update.Title != nil {
a.Title = *update.Title
}
if update.Body != nil { if update.Body != nil {
if err, failing := m.failUpdateBodyFor[id]; failing { if err, failing := m.failUpdateBodyFor[id]; failing {
return article.Article{}, err return article.Article{}, err
@@ -400,9 +502,13 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
return article.Article{}, article.ErrSlugTaken return article.Article{}, article.ErrSlugTaken
} }
applySeoUpdate(&a, update) applySeoUpdate(&a, update)
authorsChanged := update.AuthorIDs != nil && !slices.Equal(m.credits[id], *update.AuthorIDs)
if update.AuthorIDs != nil { if update.AuthorIDs != nil {
m.credits[id] = append([]uint32{}, *update.AuthorIDs...) m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
} }
if before.EditRevision > 0 {
a = a.Revised(article.ImpactOf(before, a, authorsChanged))
}
m.articles[id] = a m.articles[id] = a
return m.withAuthors(a), nil return m.withAuthors(a), nil
} }
+3 -1
View File
@@ -30,7 +30,9 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder), useCases.NewDeleteArticleUseCase(repo, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo)),
plainTranslation(repo),
plainDescribe(repo),
) )
r := chi.NewRouter() r := chi.NewRouter()
@@ -0,0 +1,391 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
const translationsPath = "/v1/article/10/translations"
func createdTranslation(t *testing.T, w *storyWorld, response storyResponse) article.Article {
t.Helper()
if response.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d %q", response.Code, response.text())
}
return w.stored(response.article(t).ID)
}
func TestArticleTranslationCreateHTTP(t *testing.T) {
t.Run("Copy from the source version", func(t *testing.T) {
w := newTranslationWorld(t)
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":true}`)
created := createdTranslation(t, w, response)
source := before[10]
if created.ID == 0 || created.Lang != "fr" || created.StoryID != storyStory || created.SiteID != storyJournal || created.IsOnline || created.Title != "Couts de renovation" {
t.Errorf("unexpected identity: %+v", created)
}
if created.Body != source.Body || created.Excerpt != source.Excerpt || created.SEOTitle != source.SEOTitle || created.SEODescription != source.SEODescription ||
created.SocialTitle != source.SocialTitle || created.SocialDescription != source.SocialDescription || created.CoverImageAlt != source.CoverImageAlt ||
*created.CoverImageID != *source.CoverImageID || *created.SocialImageID != *source.SocialImageID {
t.Errorf("the content must equal article 10: %+v", created)
}
if got := w.articles.credits[created.ID]; !reflect.DeepEqual(got, []uint32{1}) {
t.Errorf("authors must equal article 10's, got %v", got)
}
if created.Slug != "couts-de-renovation" || created.CanonicalURL != "" || created.Noindex || len(created.SEODismissed) != 0 {
t.Errorf("slug generated, canonical empty, noindex false, dismissed empty expected: %+v", created)
}
if created.CreatedBy != storyWriter || created.EditRevision != 1 {
t.Errorf("created_by must be the caller and revision 1: %+v", created)
}
if created.SourceArticleID == nil || *created.SourceArticleID != 10 || created.SourceRevision != 4 {
t.Errorf("the source must be article 10 at revision 4: %+v", created)
}
w.expectUnchanged(before, created.ID)
fields := response.fields(t)
if string(fields["revision"]) != "1" || string(fields["source"]) != `{"article_id":10,"revision":4}` {
t.Errorf("revision or source missing from the body: %s", response.text())
}
})
t.Run("Copy from a chosen source", func(t *testing.T) {
w := newTranslationWorld(t)
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Kosten","copy":true,"source_article_id":11}`)
created := createdTranslation(t, w, response)
if created.Body != "<p>Kosten</p>" || created.SourceArticleID == nil || *created.SourceArticleID != 11 || created.SourceRevision != 1 {
t.Errorf("the content must be copied from article 11: %+v", created)
}
})
t.Run("Start empty", func(t *testing.T) {
w := newTranslationWorld(t)
w.authors.authors[2] = withUser(w.authors.authors[2], storyWriter)
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":false}`)
created := createdTranslation(t, w, response)
if created.Body != "" || created.Excerpt != "" || created.SEOTitle != "" || created.SocialTitle != "" || created.CoverImageID != nil || created.SocialImageID != nil {
t.Errorf("content must be empty: %+v", created)
}
if got := w.articles.credits[created.ID]; !reflect.DeepEqual(got, []uint32{2}) {
t.Errorf("authors must be the creating user's byline, got %v", got)
}
if created.SourceArticleID != nil || string(response.fields(t)["source"]) != "null" {
t.Errorf("there must be no source: %+v %s", created, response.text())
}
})
t.Run("Request validation", func(t *testing.T) {
long := strings.Repeat("a", 201)
cases := []struct {
body string
status int
message string
}{
{`{"title":"Couts","copy":true}`, 400, ""},
{`{"lang":"fr","copy":true}`, 400, ""},
{`{"lang":"fr","title":"ab","copy":true}`, 400, ""},
{`{"lang":"fr","title":"` + long + `","copy":true}`, 400, ""},
{`{"lang":"fr","title":"Couts"}`, 400, apperr.BodyErrorMessage},
{`{"lang":"fr","title":"Couts","copy":false,"source_article_id":10}`, 400, apperr.BodyErrorMessage},
{`{"lang":"fr",`, 400, apperr.BodyErrorMessage},
{`{"lang":"english","title":"Couts","copy":true}`, 422, apperr.InvalidLanguageErrorMessage},
{`{"lang":"xx","title":"Couts","copy":true}`, 422, apperr.UnsupportedLanguageErrorMessage},
{`{"lang":"ja-JP","title":"Couts","copy":true}`, 422, apperr.UnsupportedLanguageErrorMessage},
}
for _, c := range cases {
t.Run(c.body, func(t *testing.T) {
w := newTranslationWorld(t)
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, translationsPath, c.body)
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") || response.text() == "\n" {
t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
}
w.expectUnchanged(before)
})
}
})
t.Run("The language tag is normalized", func(t *testing.T) {
w := newTranslationWorld(t)
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"FR","title":"Couts","copy":true}`)
if created := createdTranslation(t, w, response); created.Lang != "fr" {
t.Errorf("stored lang %q", created.Lang)
}
})
t.Run("Language not configured on the site", func(t *testing.T) {
w := newTranslationWorld(t)
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"pt","title":"Custos","copy":true}`)
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
w.expectUnchanged(before)
})
t.Run("One neutral answer when the language cannot be used", func(t *testing.T) {
unreadable := func(t *testing.T) storyResponse {
w := newTranslationWorld(t)
w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory})
return w.send(storyRestrict, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
}
disabled := func(t *testing.T) storyResponse {
w := newTranslationWorld(t)
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "de", "ar"}, []string{"fr"})
return w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
}
raced := func(t *testing.T) storyResponse {
w := newTranslationWorld(t)
w.articles.fail.failNextWith("Create", article.ErrLanguageTaken)
return w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
}
situations := map[string]func(*testing.T) storyResponse{"an fr version unreadable to the caller": unreadable, "fr disabled": disabled, "a concurrent request created fr first": raced}
var reference storyResponse
for name, run := range situations {
t.Run(name, func(t *testing.T) {
response := run(t)
response.expect(t, 409, apperr.LanguageUnavailableErrorMessage)
if response.header(apperr.ExistingArticleHeader) != "" {
t.Errorf("X-Existing-Article-Id must be absent")
}
if reference.ResponseRecorder == nil {
reference = response
}
if response.text() != reference.text() || !reflect.DeepEqual(response.Header(), reference.Header()) {
t.Errorf("body and headers must be identical across situations: %v vs %v", response.Header(), reference.Header())
}
})
}
})
t.Run("The language already exists and is readable", func(t *testing.T) {
w := newTranslationWorld(t)
before := w.snapshot()
german := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"de","title":"Kosten","copy":true}`)
english := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"en","title":"Costs","copy":true}`)
german.expect(t, 409, apperr.LanguageExistsErrorMessage)
english.expect(t, 409, apperr.LanguageExistsErrorMessage)
if german.header(apperr.ExistingArticleHeader) != "11" || english.header(apperr.ExistingArticleHeader) != "10" {
t.Errorf("unexpected existing ids %q %q", german.header(apperr.ExistingArticleHeader), english.header(apperr.ExistingArticleHeader))
}
w.expectUnchanged(before)
})
t.Run("Two concurrent requests for the same language", func(t *testing.T) {
w := newTranslationWorld(t)
w.articles.beforeCreate = func() {
w.putCommitted(article.Article{ID: 90, Title: "Winner", CreatedBy: storyWriter, Slug: "winner", Lang: "fr", StoryID: storyStory})
}
loser := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
loser.expect(t, 409, apperr.LanguageUnavailableErrorMessage)
count := 0
for _, stored := range w.articles.articles {
if stored.StoryID == storyStory && stored.Lang == "fr" {
count++
}
}
if count != 1 {
t.Errorf("story 5 must hold exactly one fr version, got %d", count)
}
})
t.Run("The path version and the source must be readable", func(t *testing.T) {
w := newTranslationWorld(t)
w.put(article.Article{ID: 20, Title: "Other story", CreatedBy: storyWriter1, Slug: "other", Lang: "en", StoryID: 8})
cases := []struct {
name string
user uint32
path string
body string
status int
message string
}{
{"anonymous", storyAnon, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`, 401, ""},
{"reader without content.create", storyReader, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`, 403, apperr.MissingPermissionErrorMessage},
{"missing article", storyWriter, "/v1/article/9999/translations", `{"lang":"fr","title":"Couts","copy":true}`, 404, apperr.ArticleNotFoundErrorMessage},
{"unreadable path version", storyRestrict, "/v1/article/11/translations", `{"lang":"fr","title":"Couts","copy":true}`, 404, apperr.ArticleNotFoundErrorMessage},
{"bad id", storyWriter, "/v1/article/abc/translations", `{"lang":"fr","title":"Couts","copy":true}`, 400, apperr.IdUint32ErrorMessage},
{"missing source", storyWriter, translationsPath, `{"lang":"pt","title":"Couts","copy":true,"source_article_id":9999}`, 404, apperr.SourceUnavailableErrorMessage},
{"unreadable source", storyRestrict, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`, 404, apperr.SourceUnavailableErrorMessage},
{"source of another story", storyWriter, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":20}`, 422, apperr.SourceOtherStoryErrorMessage},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
before := w.snapshot()
response := w.send(c.user, http.MethodPost, c.path, c.body)
response.expect(t, c.status, c.message)
w.expectUnchanged(before)
})
}
})
t.Run("Access to the source is rechecked at submit", func(t *testing.T) {
w := newTranslationWorld(t)
delete(w.articles.articles, 11)
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`)
response.expect(t, 404, apperr.SourceUnavailableErrorMessage)
w.expectUnchanged(before)
})
t.Run("Unassigned articles cannot be translated yet", func(t *testing.T) {
w := newTranslationWorld(t)
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: storyWriter1, Slug: "unassigned", Lang: "", StoryID: 9, IsOnline: true})
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, "/v1/article/30/translations", `{"lang":"fr","title":"Couts","copy":true}`)
response.expect(t, 409, apperr.SetupRequiredErrorMessage)
w.expectUnchanged(before)
})
t.Run("The slug of the new version is free within site and language", func(t *testing.T) {
w := newTranslationWorld(t)
w.put(article.Article{ID: 40, Title: "Taken", CreatedBy: storyWriter1, Slug: "couts-de-renovation", Lang: "fr", StoryID: 77})
clash := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":true}`))
plain := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, "/v1/article/10/translations", `{"lang":"ar","title":"Kosten Renovierung","copy":false}`))
if clash.Slug != "couts-de-renovation-2" || plain.Slug != "kosten-renovierung" {
t.Errorf("unexpected slugs %q and %q", clash.Slug, plain.Slug)
}
})
t.Run("Entry point 2 behaves exactly like entry point 1", func(t *testing.T) {
first, second := newTranslationWorld(t), newTranslationWorld(t)
viaPath := first.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":10}`)
viaCollection := second.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true}`)
if viaPath.Code != 201 || viaCollection.Code != 201 || viaPath.text() != viaCollection.text() {
t.Fatalf("responses differ: %d %s / %d %s", viaPath.Code, viaPath.text(), viaCollection.Code, viaCollection.text())
}
if !reflect.DeepEqual(first.articles.articles, second.articles.articles) {
t.Errorf("stored data differ")
}
for _, body := range []string{`{"title":"Couts","lang":"de","story_id":5,"copy":true}`, `{"title":"Couts","lang":"xx","story_id":5,"copy":true}`} {
a := newTranslationWorld(t).send(storyWriter, http.MethodPost, "/v1/article/", body)
b := newTranslationWorld(t).send(storyWriter, http.MethodPost, translationsPath, strings.Replace(body, `"story_id":5,`, "", 1))
if a.Code != b.Code || a.text() != b.text() || !reflect.DeepEqual(a.Header(), b.Header()) {
t.Errorf("errors differ for %s: %d %q / %d %q", body, a.Code, a.text(), b.Code, b.text())
}
}
})
t.Run("POST /v1/article/ is unchanged when it is not a translation", func(t *testing.T) {
cases := []struct {
body string
status int
message string
lang string
}{
{`{"title":"Plain","body":"x"}`, 201, "", "en"},
{`{"title":"Neuf","lang":"fr"}`, 201, "", "fr"},
{`{"title":"Neuf","lang":"pt"}`, 422, apperr.LanguageNotEnabledErrorMessage, ""},
{`{"title":"Neuf","lang":"es"}`, 409, apperr.LanguageUnavailableErrorMessage, ""},
{`{"title":"Neuf","story_id":5}`, 400, apperr.BodyErrorMessage, ""},
{`{"title":"Neuf","lang":"fr","story_id":999}`, 404, apperr.ArticleNotFoundErrorMessage, ""},
{`{"title":"Neuf","copy":true}`, 400, apperr.BodyErrorMessage, ""},
}
for _, c := range cases {
t.Run(c.body, func(t *testing.T) {
w := newTranslationWorld(t)
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, "/v1/article/", c.body)
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
}
if c.status != http.StatusCreated {
w.expectUnchanged(before)
return
}
created := w.stored(response.article(t).ID)
if created.Lang != c.lang || created.StoryID == storyStory {
t.Errorf("a new story in %q expected, got %+v", c.lang, created)
}
})
}
})
t.Run("PATCH still ignores lang, story_id and source fields", func(t *testing.T) {
w := newTranslationWorld(t)
response := w.send(storyWriter, http.MethodPatch, "/v1/article/10", `{"body":"x","lang":"fr","story_id":9,"source_article_id":11,"copy":true}`)
response.expect(t, 200, "")
if stored := w.stored(10); stored.Lang != "en" || stored.StoryID != storyStory || stored.SourceArticleID != nil {
t.Errorf("lang, story and source must be unchanged: %+v", stored)
}
})
t.Run("Activity records the creation", func(t *testing.T) {
w := newTranslationWorld(t)
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`))
w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"de","title":"Kosten","copy":true}`)
w.send(storyRestrict, http.MethodPost, translationsPath, `{"lang":"fr","title":"Autre","copy":true}`)
events := w.eventsOf(audit.ActionArticleCreate)
if len(events) != 3 {
t.Fatalf("expected one success and two failures, got %d", len(events))
}
success := events[0]
if success.Outcome != audit.OutcomeSuccess || success.Target.ID != idText(created.ID) || success.Params[audit.ParamLang] != "fr" || success.Params[audit.ParamStoryID] != storyStory || success.Params[audit.ParamSourceArticleID] != uint32(10) {
t.Errorf("unexpected success event %+v for article %d", success, created.ID)
}
for _, failure := range events[1:] {
if failure.Outcome != audit.OutcomeFailed || failure.FailureReason == nil || *failure.FailureReason == "" {
t.Errorf("a failed event with a plain reason expected: %+v", failure)
}
if strings.Contains(*failure.FailureReason, "11") || strings.Contains(*failure.FailureReason, "Kosten") {
t.Errorf("the reason must not name a version: %q", *failure.FailureReason)
}
}
})
t.Run("A new version does not appear publicly", func(t *testing.T) {
w := newTranslationWorld(t)
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`))
response := w.send(storyAnon, http.MethodGet, "/v1/article/"+idText(created.ID), "")
response.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
})
}
func idText(id uint32) string {
return strconv.FormatUint(uint64(id), 10)
}
func withUser(found author.Author, userID uint32) author.Author {
found.UserID = &userID
return found
}
+18
View File
@@ -0,0 +1,18 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
)
func plainStoryMover(articles article.Repository) useCases.StoryMover {
return useCases.NewMoveStoryUseCase(passthroughCreateTransactor(articles), articles, discardRecorder)
}
func plainTranslation(articles *mockArticleRepo) *useCases.CreateTranslationUseCase {
return useCases.NewCreateTranslationUseCase(passthroughCreateTransactor(articles), articles, newFakeLocalizationStore(articles), &fakeBodyRenderer{}, discardRecorder)
}
func plainDescribe(articles *mockArticleRepo) *useCases.DescribeArticleUseCase {
return useCases.NewDescribeArticleUseCase(articles, newFakeLocalizationStore(articles))
}
+347
View File
@@ -0,0 +1,347 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"net/http"
"testing"
"time"
)
func siteChangesOf(w *storyWorld) []audit.Change {
changes := []audit.Change{}
for _, event := range w.eventsOf(audit.ActionArticleEdit) {
for _, change := range event.Changes {
if change.Field == audit.FieldSite {
changes = append(changes, change)
}
}
}
return changes
}
func TestArticleWritePathEdgesStoryMove(t *testing.T) {
t.Run("A busy site lock refuses the story move", func(t *testing.T) {
w := newStoryWorld(t)
w.sites.busy = true
before := w.snapshot()
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"atelier"}`).expect(t, 409, apperr.StoryBusyErrorMessage)
w.expectUnchanged(before)
})
t.Run("The story move lists versions by creation then id", func(t *testing.T) {
w := newStoryWorld(t)
for id, stored := range w.articles.articles {
stored.CreatedAt = time.Time{}
w.articles.articles[id] = stored
}
w.articles.reverseStory = true
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"atelier"}`)
response.expect(t, 200, "")
if response.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,11,12,13]}` {
t.Errorf("unexpected body %q", response.text())
}
})
t.Run("A whole-story move through PATCH returns the moved article with the other edits", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70})
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"title":"Renamed visible"}`)
response.expect(t, 200, "")
returned := response.article(t)
if returned.ID != 70 || returned.Title != "Renamed visible" || returned.SiteID != storyAtelier {
t.Errorf("unexpected response: %+v", returned)
}
if w.stored(70).Title != "Renamed visible" || w.stored(70).SiteID != storyAtelier || w.stored(71).SiteID != storyAtelier {
t.Errorf("the story and the edit must be stored: %+v %+v", w.stored(70), w.stored(71))
}
if got := len(siteChangesOf(w)); got != 2 {
t.Errorf("one site change per moved version expected, got %d", got)
}
})
t.Run("A combined site_id and field edit raises revision by exactly 1", func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3, ContentRevision: 4})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5, ContentRevision: 6})
w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"title":"Renamed visible","if_revision":3}`).expect(t, 200, "")
if edit, content := revisionsOf(w, 70); w.stored(70).SiteID != storyAtelier || edit != 4 || content != 5 {
t.Errorf("site %d revision %d content_revision %d, expected 2, 4 and 5", w.stored(70).SiteID, edit, content)
}
if edit, _ := revisionsOf(w, 71); w.stored(71).SiteID != storyAtelier || edit != 6 {
t.Errorf("hidden version: site %d revision %d, expected 2 and 6", w.stored(71).SiteID, edit)
}
})
t.Run("A failure after the move step leaves every version on its original site", func(t *testing.T) {
cases := []struct {
name string
body string
arrange func(w *storyWorld)
status int
}{
{"unknown author", `{"site_id":2,"author_ids":[99]}`, func(*storyWorld) {}, http.StatusNotFound},
{"invalid canonical url", `{"site_id":2,"canonical_url":"not a url"}`, func(*storyWorld) {}, http.StatusUnprocessableEntity},
{"invalid slug", `{"site_id":2,"slug":"Not A Slug"}`, func(*storyWorld) {}, http.StatusBadRequest},
{"slug taken at the destination", `{"site_id":2,"slug":"claimed"}`, func(w *storyWorld) {
w.put(article.Article{ID: 72, SiteID: storyAtelier, Title: "Claimed", CreatedBy: 1, Slug: "claimed", Lang: "fr", StoryID: 72})
}, http.StatusConflict},
{"slug race on the write", `{"site_id":2,"title":"Renamed visible"}`, func(w *storyWorld) { w.articles.reportSlugRace = true }, http.StatusConflict},
{"repository failure on the write", `{"site_id":2,"title":"Renamed visible"}`, func(w *storyWorld) { w.articles.fail.failNext("Update") }, http.StatusInternalServerError},
}
for _, example := range cases {
t.Run(example.name, func(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5})
example.arrange(w)
before := w.snapshot()
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", example.body)
if response.Code != example.status {
t.Errorf("expected %d, got %d %q", example.status, response.Code, response.text())
}
w.expectUnchanged(before)
})
}
})
}
func TestArticleWritePathEdgesStoryMoveSlugRace(t *testing.T) {
w := newStoryWorld(t)
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3})
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5})
w.articles.reportSlugRace = true
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"title":"Renamed visible"}`)
response.expect(t, http.StatusConflict, apperr.ArticleSlugTakenErrorMessage)
if response.header(apperr.SlugSuggestionHeader) != "visible-2" {
t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader))
}
}
func TestArticleWritePathEdgesSingleVersionMove(t *testing.T) {
seed := func(t *testing.T) *storyWorld {
w := newStoryWorld(t)
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "fr", StoryID: 8})
return w
}
t.Run("A stale if_revision refuses the move", func(t *testing.T) {
w := seed(t)
before := w.snapshot()
response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2,"if_revision":9}`)
if response.Code != http.StatusConflict {
t.Errorf("expected 409, got %d %q", response.Code, response.text())
}
w.expectUnchanged(before)
})
t.Run("A move records the site change with both names", func(t *testing.T) {
w := seed(t)
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2,"if_revision":1}`).expect(t, 200, "")
changes := siteChangesOf(w)
if len(changes) != 1 || *changes[0].Before != "Journal" || *changes[0].After != "Atelier" {
t.Errorf("unexpected site changes %+v", changes)
}
})
t.Run("The site change falls back to the site id when the origin is unknown", func(t *testing.T) {
w := seed(t)
delete(w.sites.sites, storyJournal)
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`).expect(t, 200, "")
changes := siteChangesOf(w)
if len(changes) != 1 || *changes[0].Before != "1" || *changes[0].After != "Atelier" {
t.Errorf("unexpected site changes %+v", changes)
}
})
t.Run("Targeting the current site records no site change", func(t *testing.T) {
w := seed(t)
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":1,"title":"Alone again"}`).expect(t, 200, "")
if len(siteChangesOf(w)) != 0 {
t.Errorf("no site change expected")
}
if len(w.eventsOf(audit.ActionArticleEdit)) != 1 {
t.Errorf("the title edit must still be recorded")
}
})
t.Run("An unchanged slug on the current site skips the slug check", func(t *testing.T) {
w := seed(t)
w.articles.fail.failNext("SlugTaken")
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":1,"title":"Alone again"}`).expect(t, 200, "")
})
t.Run("A slug already used on the destination refuses the move", func(t *testing.T) {
w := seed(t)
w.put(article.Article{ID: 80, SiteID: storyAtelier, Title: "Alone", CreatedBy: 1, Slug: "alone", Lang: "fr", StoryID: 80})
before := w.snapshot()
response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`)
response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage)
if response.header(apperr.SlugSuggestionHeader) != "alone-2" {
t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader))
}
w.expectUnchanged(before)
})
t.Run("A slug race on a move keeps the slug and suggests a free one", func(t *testing.T) {
w := seed(t)
w.articles.reportSlugRace = true
before := w.snapshot()
response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`)
response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage)
if response.header(apperr.SlugSuggestionHeader) != "alone-2" {
t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader))
}
w.expectUnchanged(before)
})
t.Run("A slug race on a slug edit suggests from the requested slug", func(t *testing.T) {
w := seed(t)
w.articles.reportSlugRace = true
before := w.snapshot()
response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"slug":"new-address"}`)
response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage)
if response.header(apperr.SlugSuggestionHeader) != "new-address-2" {
t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader))
}
w.expectUnchanged(before)
})
}
func TestArticleWritePathEdgesAuthors(t *testing.T) {
t.Run("An author lookup failure aborts the edit", func(t *testing.T) {
w := newStoryWorld(t)
w.authors.fail.failNext("Get")
before := w.snapshot()
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"author_ids":[1]}`)
if response.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d %q", response.Code, response.text())
}
w.expectUnchanged(before)
})
t.Run("An unknown later author aborts the edit", func(t *testing.T) {
w := newStoryWorld(t)
before := w.snapshot()
response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"author_ids":[1,999]}`)
if response.Code == http.StatusOK {
t.Errorf("an unknown author must refuse the edit: %q", response.text())
}
w.expectUnchanged(before)
})
t.Run("A creator lookup failure aborts the creation", func(t *testing.T) {
w := newStoryWorld(t)
w.authors.fail.failNext("GetByUserID")
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Plain","body":"x"}`)
if response.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d %q", response.Code, response.text())
}
w.expectUnchanged(before)
})
}
func TestArticleWritePathEdgesSlugAttempts(t *testing.T) {
t.Run("Creation gives up after five racing attempts", func(t *testing.T) {
w := newStoryWorld(t)
w.articles.createAlwaysRaces = true
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Plain","body":"x"}`)
response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage)
if w.articles.raceCreates != 5 {
t.Errorf("expected 5 attempts, got %d", w.articles.raceCreates)
}
w.expectUnchanged(before)
})
t.Run("A translation gives up after five racing attempts", func(t *testing.T) {
w := newTranslationWorld(t)
w.articles.createAlwaysRaces = true
before := w.snapshot()
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage)
if w.articles.raceCreates != 5 {
t.Errorf("expected 5 attempts, got %d", w.articles.raceCreates)
}
w.expectUnchanged(before)
})
}
func TestArticleWritePathEdgesTranslationSource(t *testing.T) {
t.Run("A source of another story the caller cannot read is unavailable, not other-story", func(t *testing.T) {
w := newTranslationWorld(t)
w.put(article.Article{ID: 20, Title: "Other story", CreatedBy: storyOwner, Slug: "other", Lang: "en", StoryID: 8})
before := w.snapshot()
response := w.send(storyWriter1, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":20}`)
response.expect(t, 404, apperr.SourceUnavailableErrorMessage)
w.expectUnchanged(before)
})
t.Run("A source that becomes unreadable before the transaction is unavailable", func(t *testing.T) {
w := newTranslationWorld(t)
w.put(article.Article{ID: 11, Title: "Kosten", Body: "<p>Kosten</p>", CreatedBy: storyWriter1, Slug: "kosten", Lang: "de", StoryID: storyStory})
calls := 0
w.articles.onGet = func(id uint32) error {
if id != 11 {
return nil
}
calls++
if calls == 2 {
hidden := w.articles.articles[11]
hidden.CreatedBy = storyOwner
w.articles.articles[11] = hidden
}
return nil
}
count := len(w.articles.articles)
response := w.send(storyWriter1, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`)
response.expect(t, 404, apperr.SourceUnavailableErrorMessage)
if len(w.articles.articles) != count {
t.Errorf("no translation may be created")
}
})
}
+3 -1
View File
@@ -24,7 +24,9 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+3 -1
View File
@@ -360,7 +360,9 @@ func newAuthorEnv(t *testing.T) *authorEnv {
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
c.CreateTranslationUseCase = plainTranslation(p.articles)
c.DescribeArticleUseCase = plainDescribe(p.articles)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.MergeMediaUseCase = mergeMedia c.MergeMediaUseCase = mergeMedia
+3 -1
View File
@@ -28,7 +28,9 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+4
View File
@@ -32,8 +32,12 @@ func TestDriverLockContention(t *testing.T) {
{"sqlite plain error", database.DriverSQLite, errors.New("database is locked"), false}, {"sqlite plain error", database.DriverSQLite, errors.New("database is locked"), false},
{"mysql nowait", database.DriverMySQL, &mysqldriver.MySQLError{Number: 3572}, true}, {"mysql nowait", database.DriverMySQL, &mysqldriver.MySQLError{Number: 3572}, true},
{"mysql lock wait timeout", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1205}), true}, {"mysql lock wait timeout", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1205}), true},
{"mysql deadlock", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1213}), true},
{"mariadb record changed since last read", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1020}, true},
{"mysql duplicate", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1062}, false}, {"mysql duplicate", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1062}, false},
{"postgres lock not available", database.DriverPostgres, &pgconn.PgError{Code: "55P03"}, true}, {"postgres lock not available", database.DriverPostgres, &pgconn.PgError{Code: "55P03"}, true},
{"postgres deadlock detected", database.DriverPostgres, &pgconn.PgError{Code: "40P01"}, true},
{"postgres serialization failure", database.DriverPostgres, fmt.Errorf("wrapped: %w", &pgconn.PgError{Code: "40001"}), true},
{"postgres unique violation", database.DriverPostgres, &pgconn.PgError{Code: "23505"}, false}, {"postgres unique violation", database.DriverPostgres, &pgconn.PgError{Code: "23505"}, false},
{"unknown driver", "oracle", codedSQLiteError{5}, false}, {"unknown driver", "oracle", codedSQLiteError{5}, false},
{"nil error", database.DriverSQLite, nil, false}, {"nil error", database.DriverSQLite, nil, false},
+3 -1
View File
@@ -70,7 +70,9 @@ func newMediaEnv(t *testing.T) *mediaEnv {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images, newFakeLocalizationStore(env.articles), plainStoryMover(env.articles)),
CreateTranslationUseCase: plainTranslation(env.articles),
DescribeArticleUseCase: plainDescribe(env.articles),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
+3 -1
View File
@@ -130,7 +130,9 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
+3 -1
View File
@@ -46,7 +46,9 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+3 -1
View File
@@ -28,7 +28,9 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+25 -11
View File
@@ -234,11 +234,11 @@ func TestContentScopeArticleUseCases(t *testing.T) {
id uint32 id uint32
allowed bool allowed bool
}{ }{
{"update own", updateCall, "content.update", callerWith(21, "content.update=own"), 10, true}, {"update own", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 10, true},
{"update foreign", updateCall, "content.update", callerWith(21, "content.update=own"), 11, false}, {"update foreign", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 11, false},
{"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own"), 14, false}, {"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 14, false},
{"update all", updateCall, "content.update", callerWith(9, "content.update=all"), 14, true}, {"update all", updateCall, "content.update", callerWith(9, "content.update=all", "content.read=all"), 14, true},
{"update refused by a typed none row", updateCall, "content.update", callerWith(25, "content.update=all", "content.update:article=none"), 10, false}, {"update refused by a typed none row", updateCall, "content.update", callerWith(25, "content.update=all", "content.read=all", "content.update:article=none"), 10, false},
{"publish own", publishCall, "content.publish", callerWith(24, "content.publish=own"), 17, true}, {"publish own", publishCall, "content.publish", callerWith(24, "content.publish=own"), 17, true},
{"publish foreign", publishCall, "content.publish", callerWith(24, "content.publish=own"), 10, false}, {"publish foreign", publishCall, "content.publish", callerWith(24, "content.publish=own"), 10, false},
{"publish is not implied by update", publishCall, "content.publish", callerWith(9, "content.update=all"), 10, false}, {"publish is not implied by update", publishCall, "content.publish", callerWith(9, "content.update=all"), 10, false},
@@ -267,15 +267,29 @@ func TestContentScopeArticleUseCases(t *testing.T) {
} }
}) })
t.Run("Update of an article the caller cannot read is not found, after the permission check", func(t *testing.T) {
repo, sites, authors, images := newRepos()
unreadable := repo.articles[10]
unreadable.IsOnline = false
repo.articles[10] = unreadable
snapshot := repo.articles[10]
err := updateCall(repo, sites, authors, images, callerWith(21, "content.update=own"), 10)
if !errors.Is(err, article.ErrArticleNotFound) || !sameArticle(repo.articles[10], snapshot) {
t.Errorf("expected not found and no change, got %v", err)
}
err = updateCall(repo, sites, authors, images, callerWith(21, "content.read=all"), 10)
requireMissingKey(t, err, "content.update")
})
t.Run("A refused update performs no site, author or image lookup and records nothing", func(t *testing.T) { t.Run("A refused update performs no site, author or image lookup and records nothing", func(t *testing.T) {
repo, sites, authors, images := newRepos() repo, sites, authors, images := newRepos()
sites.fail.failNext("Get") sites.fail.failNext("Get")
authors.fail.failNext("Get") authors.fail.failNext("Get")
images.fail.failNext("Get") images.fail.failNext("Get")
recorder := &contentScopeRecorder{} recorder := &contentScopeRecorder{}
update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, recorder, sites, authors, images) update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, recorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo))
missingSite := uint32(5) missingSite := uint32(5)
_, err := update.Update(testCtx, 11, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{SiteID: &missingSite, AuthorIDs: &[]uint32{7}}) _, err := update.Update(testCtx, 11, callerWith(21, "content.update=own", "content.read=all"), useCases.UpdateArticleCommand{SiteID: &missingSite, AuthorIDs: &[]uint32{7}})
requireMissingKey(t, err, "content.update") requireMissingKey(t, err, "content.update")
if sites.fail.method != "Get" || authors.fail.method != "Get" || len(recorder.events) != 0 { if sites.fail.method != "Get" || authors.fail.method != "Get" || len(recorder.events) != 0 {
t.Errorf("a refused update must not look anything up or record an event") t.Errorf("a refused update must not look anything up or record an event")
@@ -284,9 +298,9 @@ func TestContentScopeArticleUseCases(t *testing.T) {
t.Run("An unknown id is not found even under own scope", func(t *testing.T) { t.Run("An unknown id is not found even under own scope", func(t *testing.T) {
repo, sites, authors, images := newRepos() repo, sites, authors, images := newRepos()
update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images) update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo))
body := "x" body := "x"
_, err := update.Update(testCtx, 999, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{Body: &body}) _, err := update.Update(testCtx, 999, callerWith(21, "content.update=own", "content.read=all"), useCases.UpdateArticleCommand{Body: &body})
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected not found, got %v", err) t.Errorf("expected not found, got %v", err)
} }
@@ -344,12 +358,12 @@ func sortIDs(ids []uint32) {
func updateCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error { func updateCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error {
body := "new" body := "new"
_, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).Update(testCtx, id, caller, useCases.UpdateArticleCommand{Body: &body}) _, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo)).Update(testCtx, id, caller, useCases.UpdateArticleCommand{Body: &body})
return err return err
} }
func publishCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error { func publishCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error {
_, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).UpdateIsOnline(testCtx, id, caller, true, article.BodyFormatHTML) _, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo)).UpdateIsOnline(testCtx, id, caller, true, article.BodyFormatHTML)
return err return err
} }
+3 -1
View File
@@ -133,7 +133,9 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
CreateTranslationUseCase: plainTranslation(articleRepo),
DescribeArticleUseCase: plainDescribe(articleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
+1 -1
View File
@@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
migrateSeo(t, db) migrateSeo(t, db)
found, err := persistence.NewArticleRepository(db).Get(1) found, err := persistence.NewArticleRepository(db).Get(1)
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1} want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1, EditRevision: 1}
if err != nil || !reflect.DeepEqual(found, want) { if err != nil || !reflect.DeepEqual(found, want) {
t.Errorf("found %+v, %v", found, err) t.Errorf("found %+v, %v", found, err)
} }
+4 -2
View File
@@ -56,7 +56,9 @@ func newSeoEnv(t *testing.T) *seoEnv {
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
c.CreateTranslationUseCase = plainTranslation(p.articles)
c.DescribeArticleUseCase = plainDescribe(p.articles)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
@@ -79,7 +81,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
} }
func (e *seoEnv) seedArticle(id uint32, title string, siteID uint32, slug string) article.Article { func (e *seoEnv) seedArticle(id uint32, title string, siteID uint32, slug string) article.Article {
seeded := article.Article{ID: id, Title: title, Body: "Body of " + title, SiteID: siteID, Slug: slug, SEODismissed: []string{}} seeded := article.Article{ID: id, Title: title, Body: "Body of " + title, SiteID: siteID, Slug: slug, StoryID: id, SEODismissed: []string{}}
e.articles.articles[id] = seeded e.articles.articles[id] = seeded
if id > e.articles.nextID { if id > e.articles.nextID {
e.articles.nextID = id e.articles.nextID = id
+17 -3
View File
@@ -2,6 +2,7 @@ package test
import ( import (
articlehandler "RenewCMS/api/handlers/article" articlehandler "RenewCMS/api/handlers/article"
"RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"math" "math"
@@ -59,12 +60,11 @@ func TestSeoOutputListPagingNeverPanics(t *testing.T) {
} }
func TestSeoOutputContractParity(t *testing.T) { func TestSeoOutputContractParity(t *testing.T) {
oneof := "oneof=" + strings.Join(article.DismissCodes(), " ")
max := "max=" + strconv.Itoa(article.MaxCanonicalURLRunes) max := "max=" + strconv.Itoa(article.MaxCanonicalURLRunes)
for _, subject := range []reflect.Type{reflect.TypeOf(articlehandler.CheckArticleSEOBody{}), reflect.TypeOf(articlehandler.UpdateArticleBody{})} { for _, subject := range []reflect.Type{reflect.TypeOf(articlehandler.CheckArticleSEOBody{}), reflect.TypeOf(articlehandler.UpdateArticleBody{})} {
dismissed, _ := subject.FieldByName("SEODismissed") dismissed, _ := subject.FieldByName("SEODismissed")
if !strings.HasSuffix(dismissed.Tag.Get("validate"), oneof) { if !strings.HasSuffix(dismissed.Tag.Get("validate"), validator.SEODismissedTag) {
t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), dismissed.Tag.Get("validate"), oneof) t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), dismissed.Tag.Get("validate"), validator.SEODismissedTag)
} }
canonical, _ := subject.FieldByName("CanonicalURL") canonical, _ := subject.FieldByName("CanonicalURL")
if !strings.Contains(canonical.Tag.Get("validate"), max) { if !strings.Contains(canonical.Tag.Get("validate"), max) {
@@ -82,3 +82,17 @@ func TestSeoOutputContractParity(t *testing.T) {
t.Errorf("seo=bogus: %d", rec.Code) t.Errorf("seo=bogus: %d", rec.Code)
} }
} }
func TestSeoDismissedValidatorFollowsTheDomainCodes(t *testing.T) {
validator.InitValidator()
for _, code := range article.DismissCodes() {
request := articlehandler.UpdateArticleBody{SEODismissed: &[]string{code}}
if err := validator.Validate.Struct(request); err != nil {
t.Errorf("%s must be accepted: %v", code, err)
}
}
request := articlehandler.UpdateArticleBody{SEODismissed: &[]string{"bogus"}}
if err := validator.Validate.Struct(request); err == nil {
t.Error("an unknown code must be refused")
}
}
+1 -1
View File
@@ -294,7 +294,7 @@ func TestArticlesBelongToASite(t *testing.T) {
}) })
t.Run("An empty or unknown update is refused", func(t *testing.T) { t.Run("An empty or unknown update is refused", func(t *testing.T) {
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"title": "New title"}`, `{"body": null}`} { for _, body := range []string{`{}`, `{"colour": "x"}`, `{"body": null}`} {
t.Run(body, func(t *testing.T) { t.Run(body, func(t *testing.T) {
e := newSiteEnvWithShop(t) e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true) e.seedTitled(8, siteShopID, "Spring release", true)
+1 -1
View File
@@ -116,7 +116,7 @@ func TestSiteUseCaseRules(t *testing.T) {
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1} repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
sites := newMockSiteRepo(repo) sites := newMockSiteRepo(repo)
sites.seed(1, "Main", "main", true) sites.seed(1, "Main", "main", true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
missing := uint32(9) missing := uint32(9)
_, err := uc.Update(testCtx, 1, ownerCaller(1), useCases.UpdateArticleCommand{SiteID: &missing}) _, err := uc.Update(testCtx, 1, ownerCaller(1), useCases.UpdateArticleCommand{SiteID: &missing})
+3 -1
View File
@@ -327,7 +327,9 @@ func newSiteEnv(t *testing.T) *siteEnv {
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()) mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c := p.container c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors()) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
c.CreateTranslationUseCase = plainTranslation(p.articles)
c.DescribeArticleUseCase = plainDescribe(p.articles)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles)
+1 -1
View File
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"} var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision"} var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision", "edit_revision", "source_article_id", "source_revision", "retired_id"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper() t.Helper()
+3 -1
View File
@@ -27,7 +27,9 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
+76
View File
@@ -0,0 +1,76 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"slices"
"testing"
)
const storyStudio uint32 = 3
type shiftingStoryRepo struct {
*mockArticleRepo
listings int
shiftFrom int
shift func()
}
func (r *shiftingStoryRepo) ListByStory(storyID uint32) ([]article.Article, error) {
r.listings++
if r.listings >= r.shiftFrom {
r.shift()
}
return r.mockArticleRepo.ListByStory(storyID)
}
type repositoriesTransactor struct {
repositories transaction.Repositories
}
func (r repositoriesTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
return work(r.repositories)
}
func TestMoveStoryRelocksUntilTheSiteSetIsStable(t *testing.T) {
t.Run("A version that appears on another site after the first lock gets that site locked too", func(t *testing.T) {
w := newStoryWorld(t)
w.sites.seed(storyStudio, "Studio", "studio", false)
listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 2}
listing.shift = func() {
shifted := w.articles.articles[13]
shifted.SiteID = storyStudio
w.articles.articles[13] = shifted
}
mover := useCases.NewMoveStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites, Authors: w.authors, Localization: w.rows}}, listing, discardRecorder)
_, _ = mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: storyStory, SiteKey: "atelier", Caller: ownerCaller(storyOwner)})
if !slices.Contains(w.sites.lockedSites, storyStudio) {
t.Errorf("site %d must be locked, locked %v", storyStudio, w.sites.lockedSites)
}
})
t.Run("A set that never settles is refused as lock contention", func(t *testing.T) {
w := newStoryWorld(t)
listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 2}
listing.shift = func() {
shifted := w.articles.articles[13]
shifted.SiteID = 100 + uint32(listing.listings)
w.articles.articles[13] = shifted
}
mover := useCases.NewMoveStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites, Authors: w.authors, Localization: w.rows}}, listing, discardRecorder)
_, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: storyStory, SiteKey: "atelier", Caller: ownerCaller(storyOwner)})
if !errors.Is(err, transaction.ErrLockContention) {
t.Errorf("expected lock contention, got %v", err)
}
if listing.listings != 7 {
t.Errorf("expected two listings plus five relock rounds, got %d listings", listing.listings)
}
})
}
+298
View File
@@ -0,0 +1,298 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"RenewCMS/api/pkg/validator"
"github.com/go-chi/chi/v5"
)
const (
storyJournal uint32 = 1
storyAtelier uint32 = 2
storyWriter1 uint32 = 1
storyOwner uint32 = 50
storyReader uint32 = 51
storyWriter uint32 = 52
storyRestrict uint32 = 53
storyUpdater uint32 = 54
storyPublish uint32 = 55
storyNobody uint32 = 56
storyPartial uint32 = 57
storyAnon uint32 = 0
storyStory uint32 = 5
)
type storyWorld struct {
t *testing.T
token *authtoken.Auth
users *mockUserRepo
roles *mockRoleRepo
articles *mockArticleRepo
sites *mockSiteRepo
rows *fakeLocalizationStore
authors *mockAuthorRepo
images *mockImageRepo
recorder *contentScopeRecorder
transactor *storyTransactor
router http.Handler
}
type storyTransactor struct {
world *storyWorld
runs int
external map[uint32]article.Article
}
var _ transaction.Transactor = &storyTransactor{}
func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
s.runs++
w := s.world
articles := maps.Clone(w.articles.articles)
credits := maps.Clone(w.articles.credits)
rows, _ := w.rows.snapshot()
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: map[uint32]string{storyJournal: "en", storyAtelier: "en"}}})
if err != nil {
w.articles.articles = articles
w.articles.credits = credits
w.rows.rows = rows
maps.Copy(w.articles.articles, s.external)
}
return err
}
var storyEpoch = time.Date(2026, time.October, 1, 9, 0, 0, 0, time.UTC)
func storyRows(defaultTag string, enabled []string, disabled []string) []localization.Row {
rows := []localization.Row{}
for _, tag := range enabled {
rows = append(rows, localization.Row{Tag: tag, IsDefault: tag == defaultTag, Enabled: true, Confirmed: tag == defaultTag})
}
for _, tag := range disabled {
rows = append(rows, localization.Row{Tag: tag, Enabled: false})
}
return rows
}
func newStoryWorld(t *testing.T) *storyWorld {
t.Helper()
validator.InitValidator()
w := &storyWorld{t: t, token: authtoken.New([]byte("test-secret")), users: newMockUserRepo(), roles: newSeededMockRoleRepo(), articles: newMockArticleRepo(), recorder: &contentScopeRecorder{}}
w.sites = newMockSiteRepo(w.articles)
w.sites.seed(storyJournal, "Journal", "journal", true)
w.sites.seed(storyAtelier, "Atelier", "atelier", false)
w.authors = newMockAuthorRepo(w.articles)
w.images = newMockImageRepo()
for _, id := range []uint32{3, 4, 7, 8} {
w.images.images[id] = imageDomain.Image{ID: id, Filename: "image" + strconv.FormatUint(uint64(id), 10) + ".png"}
}
w.authors.seed(1, "Camille", "camille")
w.authors.seed(2, "Noor", "noor")
w.rows = newFakeLocalizationStore(w.articles)
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar"}, []string{"es"})
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de", "ar"}, nil)
w.transactor = &storyTransactor{world: w}
w.seedUsers()
w.seedStory()
w.wire()
return w
}
func (w *storyWorld) seedUsers() {
w.addUser(storyOwner, ownerRoleID, "owner")
custom := map[uint32][]string{
storyWriter1: {"content.create=all", "content.read=own", "content.update=own", "content.publish=own", "content.delete=own"},
storyReader: {"content.read=all"},
storyWriter: {"content.create=all", "content.update=all", "content.read=all"},
storyRestrict: {"content.create=all", "content.read=own"},
storyUpdater: {"content.update=all", "content.read=all"},
storyPublish: {"content.publish=all", "content.read=all"},
storyNobody: {"schema.view=all"},
storyPartial: {"content.update=all", "content.read=own"},
}
for id, grants := range custom {
created, _ := w.roles.Create(role.Role{Name: "story-role-" + strconv.FormatUint(uint64(id), 10), Version: 1, Grants: grantRows(grants...)})
w.addUser(id, created.ID, "user"+strconv.FormatUint(uint64(id), 10))
}
}
func (w *storyWorld) addUser(id uint32, roleID uint32, name string) {
w.users.users[id] = user.User{ID: id, Username: name, Email: name + "@example.com", IsVerified: true, RoleID: roleID}
}
func (w *storyWorld) put(a article.Article) article.Article {
if a.SiteID == 0 {
a.SiteID = storyJournal
}
if a.EditRevision == 0 {
a.EditRevision = 1
}
if a.ContentRevision == 0 {
a.ContentRevision = 1
}
if a.SEODismissed == nil {
a.SEODismissed = []string{}
}
w.articles.articles[a.ID] = a
if a.ID > w.articles.nextID {
w.articles.nextID = a.ID
}
return a
}
func (w *storyWorld) putCommitted(a article.Article) {
w.put(a)
if w.transactor.external == nil {
w.transactor.external = map[uint32]article.Article{}
}
w.transactor.external[a.ID] = w.stored(a.ID)
}
func (w *storyWorld) seedStory() {
cover, social := uint32(3), uint32(4)
w.put(article.Article{ID: 10, Title: "Renovation costs", Body: "<p>Costs</p>", IsOnline: true, CreatedBy: storyWriter1, Slug: "renovation-costs", Lang: "en", StoryID: storyStory,
Excerpt: "Excerpt", SEOTitle: "SEO title", SEODescription: "SEO description", SocialTitle: "Social title", SocialDescription: "Social description",
CoverImageID: &cover, CoverImageAlt: "Cover alt", SocialImageID: &social, CanonicalURL: "https://journal.example/renovation", Noindex: true, SEODismissed: []string{"short"},
EditRevision: 3, ContentRevision: 4, CreatedAt: storyEpoch})
w.articles.credit(10, 1)
w.put(article.Article{ID: 12, Title: "Couts", Body: "<p>Couts</p>", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(time.Minute)})
w.put(article.Article{ID: 11, Title: "Kosten", Body: "<p>Kosten</p>", CreatedBy: 2, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * time.Minute)})
w.put(article.Article{ID: 13, Title: "Takalif", Body: "<p>Takalif</p>", IsOnline: true, CreatedBy: 3, Slug: "takalif", Lang: "ar", StoryID: storyStory, CreatedAt: storyEpoch.Add(3 * time.Minute)})
}
func (w *storyWorld) wire() {
renderer := &fakeBodyRenderer{}
keys := newMockAPIKeyRepo()
moveStory := useCases.NewMoveStoryUseCase(w.transactor, w.articles, w.recorder)
container := &api.UseCases{
Token: w.token,
GetUserUseCase: useCases.NewGetUserUseCase(w.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(w.roles),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(keys, nil, w.users, time.Now),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(w.transactor, renderer, w.recorder, w.sites, w.authors),
GetArticleUseCase: useCases.NewGetArticleUseCase(w.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(w.articles, renderer, w.sites),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(w.articles, w.recorder),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(w.articles, renderer, w.recorder, w.sites, w.authors, w.images, w.rows, moveStory),
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder),
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(w.articles, w.rows),
MoveStoryUseCase: moveStory,
}
router := chi.NewRouter()
router.Use(authtoken.Verifier(w.token))
router.Mount("/v1/article", api.NewArticleRouter(container, nil))
router.Mount("/v1/story", api.NewStoryRouter(container, nil))
w.router = router
}
type storyResponse struct {
*httptest.ResponseRecorder
}
func (w *storyWorld) send(userID uint32, method string, path string, body string) storyResponse {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = activityPeer
if userID != storyAnon {
tokenString, _ := w.token.Encode(map[string]any{"user_id": userID, "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
w.router.ServeHTTP(rec, req)
return storyResponse{rec}
}
func (r storyResponse) text() string {
return r.Body.String()
}
func (r storyResponse) fields(t *testing.T) map[string]json.RawMessage {
t.Helper()
return decodeArticleFields(t, r.Body.Bytes())
}
func (r storyResponse) article(t *testing.T) article.Article {
t.Helper()
var decoded article.Article
if err := json.Unmarshal(r.Body.Bytes(), &decoded); err != nil {
t.Fatalf("invalid article json %s: %v", r.Body, err)
}
return decoded
}
func (r storyResponse) expect(t *testing.T, status int, body string) {
t.Helper()
if r.Code != status || (body != "" && r.text() != body+"\n") {
t.Fatalf("expected %d %q, got %d %q", status, body, r.Code, r.text())
}
}
func (r storyResponse) header(name string) string {
return r.Header().Get(name)
}
func (w *storyWorld) stored(id uint32) article.Article {
return w.articles.articles[id]
}
func (w *storyWorld) snapshot() map[uint32]article.Article {
return maps.Clone(w.articles.articles)
}
func (w *storyWorld) expectUnchanged(before map[uint32]article.Article, added ...uint32) {
w.t.Helper()
if len(before)+len(added) != len(w.articles.articles) {
w.t.Fatalf("the article count changed from %d to %d", len(before), len(w.articles.articles))
}
for id, stored := range before {
if current := w.articles.articles[id]; !articlesEqual(stored, current) {
w.t.Fatalf("article %d changed: %+v -> %+v", id, stored, current)
}
}
}
func articlesEqual(left article.Article, right article.Article) bool {
left.SEODismissed, right.SEODismissed = slices.Clone(left.SEODismissed), slices.Clone(right.SEODismissed)
leftJSON, _ := json.Marshal(left)
rightJSON, _ := json.Marshal(right)
return string(leftJSON) == string(rightJSON) && left.EditRevision == right.EditRevision && left.ContentRevision == right.ContentRevision && left.UpdatedAt.Equal(right.UpdatedAt) && left.StoryID == right.StoryID && left.Lang == right.Lang
}
func (w *storyWorld) eventsOf(action string) []audit.Event {
events := []audit.Event{}
for _, event := range w.recorder.events {
if event.Action == action {
events = append(events, event)
}
}
return events
}
func newTranslationWorld(t *testing.T) *storyWorld {
t.Helper()
w := newStoryWorld(t)
delete(w.articles.articles, 12)
delete(w.articles.articles, 13)
return w
}
+4 -4
View File
@@ -244,7 +244,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) { t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
result, err := uc.UpdateBody(testCtx, 1, ownerCaller(1), "New body", article.BodyFormatHTML) result, err := uc.UpdateBody(testCtx, 1, ownerCaller(1), "New body", article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -256,7 +256,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) { t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
_, err := uc.UpdateBody(testCtx, 9, ownerCaller(1), "New body", article.BodyFormatHTML) _, err := uc.UpdateBody(testCtx, 9, ownerCaller(1), "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -265,7 +265,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) { t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
result, err := uc.UpdateIsOnline(testCtx, 1, ownerCaller(1), true, article.BodyFormatHTML) result, err := uc.UpdateIsOnline(testCtx, 1, ownerCaller(1), true, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -277,7 +277,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) { t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
_, err := uc.UpdateIsOnline(testCtx, 9, ownerCaller(1), true, article.BodyFormatHTML) _, err := uc.UpdateIsOnline(testCtx, 9, ownerCaller(1), true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
+3 -1
View File
@@ -25,7 +25,9 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
+3 -1
View File
@@ -217,7 +217,9 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
plainTranslation(articleRepo),
plainDescribe(articleRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()