feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5722
-247
No files matched your search
@@ -59,6 +59,9 @@ type UseCases struct {
|
||||
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
||||
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
||||
MoveStoryUseCase *useCases.MoveStoryUseCase
|
||||
ListAuthorsUseCase *useCases.ListAuthorsUseCase
|
||||
CreateAuthorUseCase *useCases.CreateAuthorUseCase
|
||||
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
|
||||
@@ -385,6 +388,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
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)
|
||||
|
||||
moveStory := useCases.NewMoveStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder)
|
||||
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, 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),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
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),
|
||||
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),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
|
||||
|
||||
@@ -3,11 +3,10 @@ package article
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/articleerr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -26,25 +25,40 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateArticleUseCase
|
||||
GetUseCase *useCases.GetArticleUseCase
|
||||
ListUseCase *useCases.ListArticlesUseCase
|
||||
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||
createUseCase *useCases.CreateArticleUseCase
|
||||
getUseCase *useCases.GetArticleUseCase
|
||||
listUseCase *useCases.ListArticlesUseCase
|
||||
deleteUseCase *useCases.DeleteArticleUseCase
|
||||
updateUseCase *useCases.UpdateArticleUseCase
|
||||
translationUseCase *useCases.CreateTranslationUseCase
|
||||
describeUseCase *useCases.DescribeArticleUseCase
|
||||
}
|
||||
|
||||
type PostArticle struct {
|
||||
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||
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{
|
||||
CreateUseCase: create,
|
||||
GetUseCase: get,
|
||||
ListUseCase: list,
|
||||
DeleteUseCase: delete,
|
||||
UpdateUseCase: update,
|
||||
createUseCase: create,
|
||||
getUseCase: get,
|
||||
listUseCase: list,
|
||||
deleteUseCase: delete,
|
||||
updateUseCase: update,
|
||||
translationUseCase: translation,
|
||||
describeUseCase: describe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +75,7 @@ type baseArticleResponse struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
Revision uint32 `json:"revision"`
|
||||
}
|
||||
|
||||
type articleResponse struct {
|
||||
@@ -77,6 +92,9 @@ type articleResponse struct {
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Noindex bool `json:"noindex"`
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Translations []domain.Translation `json:"translations"`
|
||||
Source *domain.Source `json:"source"`
|
||||
}
|
||||
|
||||
type listItemResponse struct {
|
||||
@@ -121,10 +139,11 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
Lang: article.Lang,
|
||||
StoryID: article.StoryID,
|
||||
Revision: article.EditRevision,
|
||||
}, 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)
|
||||
if err != nil {
|
||||
return articleResponse{}, err
|
||||
@@ -147,6 +166,9 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
|
||||
CanonicalURL: article.CanonicalURL,
|
||||
Noindex: article.Noindex,
|
||||
SEODismissed: dismissed,
|
||||
Capabilities: described.Capabilities,
|
||||
Translations: described.Translations,
|
||||
Source: described.Source,
|
||||
}, 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
|
||||
}
|
||||
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format, viewer)
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format, viewer, described)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,25 +214,13 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
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 errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
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)
|
||||
}
|
||||
h.writeArticle(w, r, http.StatusOK, article, format, printToStdout)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
Body: localArticle.Body,
|
||||
Format: format,
|
||||
Lang: lang,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||
}
|
||||
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) {
|
||||
@@ -257,46 +332,13 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
|
||||
if access.WriteIfMissingPermission(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
|
||||
return access.WriteIfMissingPermission(w, err) || articleerr.Write(w, err)
|
||||
}
|
||||
|
||||
func writeSlugTaken(w http.ResponseWriter, err error) {
|
||||
var conflict *domain.SlugTakenError
|
||||
if errors.As(err, &conflict) && conflict.Suggestion != "" {
|
||||
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
|
||||
}
|
||||
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
|
||||
var bodyMismatchFailures = map[string]string{
|
||||
"Copy": "required",
|
||||
"IfRevision": "min",
|
||||
"AuthorIDs": "unique",
|
||||
}
|
||||
|
||||
func writeValidationError(w http.ResponseWriter, err error) {
|
||||
@@ -308,12 +350,26 @@ func writeValidationError(w http.ResponseWriter, err error) {
|
||||
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)
|
||||
}
|
||||
|
||||
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
func printToStdout(format string, args ...any) {
|
||||
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 {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -323,7 +379,7 @@ func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
type optionalImageID struct {
|
||||
@@ -372,9 +428,11 @@ func (o optionalImageID) toNullable() domain.Nullable[uint32] {
|
||||
}
|
||||
|
||||
type UpdateArticleBody struct {
|
||||
Title *string `json:"title"`
|
||||
IfRevision *uint32 `json:"if_revision" validate:"omitempty,min=1"`
|
||||
Body *string `json:"body"`
|
||||
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"`
|
||||
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
|
||||
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
|
||||
@@ -386,24 +444,13 @@ type UpdateArticleBody struct {
|
||||
SocialImageID optionalImageID `json:"social_image_id"`
|
||||
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
|
||||
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"}
|
||||
|
||||
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) {
|
||||
known := 0
|
||||
for name, value := range fields {
|
||||
@@ -430,16 +477,13 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
known, ok := countKnownFields(fields, nonNullableArticleFields)
|
||||
if !ok || known == 0 {
|
||||
if !ok || known == 0 || string(fields["if_revision"]) == "null" {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
var request UpdateArticleBody
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
@@ -466,7 +510,9 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
SiteID: request.SiteID,
|
||||
AuthorIDs: request.AuthorIDs,
|
||||
@@ -489,7 +535,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -581,7 +627,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
Page: query.Page,
|
||||
@@ -625,15 +671,10 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ type CheckArticleSEOBody struct {
|
||||
SocialImageID optionalImageID `json:"social_image_id"`
|
||||
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
|
||||
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"}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -155,3 +155,18 @@ const EmailUnavailableErrorCode = "email_unavailable"
|
||||
const LastAdminErrorCode = "last_admin"
|
||||
const AccountNotDeletableErrorCode = "account_not_deletable"
|
||||
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"
|
||||
@@ -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, ¬Enabled):
|
||||
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,6 +7,7 @@ import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
@@ -19,6 +20,7 @@ const AuthorNameTag = "authorname"
|
||||
const AuthorSlugTag = "authorslug"
|
||||
const SocialTypeTag = "socialtype"
|
||||
const ArticleSlugTag = "articleslug"
|
||||
const SEODismissedTag = "seodismissed"
|
||||
|
||||
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||
|
||||
@@ -34,6 +36,7 @@ func InitValidator() {
|
||||
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
|
||||
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
|
||||
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
|
||||
_ = Validate.RegisterValidation(SEODismissedTag, isSEODismissed)
|
||||
}
|
||||
|
||||
func isArticleSlug(field validator.FieldLevel) bool {
|
||||
@@ -44,6 +47,10 @@ func isArticleSlug(field validator.FieldLevel) bool {
|
||||
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 {
|
||||
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
|
||||
}
|
||||
|
||||
+20
-1
@@ -20,6 +20,7 @@ import (
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
"RenewCMS/api/handlers/story"
|
||||
"RenewCMS/api/handlers/update"
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
@@ -95,7 +96,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(container.apiKeyAuth())
|
||||
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)
|
||||
|
||||
@@ -110,6 +111,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
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.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
r := chi.NewRouter()
|
||||
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.Use(authtoken.Verifier(container.Token))
|
||||
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("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
|
||||
+6
-6
@@ -3,7 +3,7 @@
|
||||
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
|
||||
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
|
||||
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
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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) |
|
||||
| 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 |
|
||||
@@ -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>`
|
||||
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
|
||||
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
|
||||
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
|
||||
body-mismatch message. `PATCH /v1/article/{id}` takes `title`, `body`, `site_id`, `author_ids`, the SEO
|
||||
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
|
||||
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.
|
||||
@@ -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` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -1,8 +1,10 @@
|
||||
# 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.
|
||||
|
||||
`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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 `|`.
|
||||
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop.
|
||||
|
||||
## Writes
|
||||
|
||||
- A title cannot be changed after creation. Create a new article instead.
|
||||
- `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.
|
||||
- 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 `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.
|
||||
- Ids in paths are unsigned integers. Anything else answers `400`.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export RENEWCMS_API_KEY=rcms_...
|
||||
|
||||
## 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
|
||||
curl -X POST "{{BASE_URL}}/v1/article/" \
|
||||
|
||||
@@ -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"
|
||||
@@ -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 | public article read of an online article |
|
||||
|
||||
Scenario: Article write payloads cannot set lang or story_id
|
||||
When POST or PATCH /v1/article carries "lang" or "story_id"
|
||||
Scenario: PATCH ignores lang and story_id
|
||||
When PATCH /v1/article/{id} carries "lang" or "story_id"
|
||||
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
|
||||
Then the existing article, pagination, publishing and SEO tests pass without edits
|
||||
|
||||
@@ -5,7 +5,7 @@ Backend only. Go tests in test/, black-box. No design states. No new endpoints.
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## Automated, SQLite file (task test)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.
|
||||
|
||||
Extended by article_story_lifecycle: publishing a version in a disabled language answers 422 language_disabled.
|
||||
|
||||
As an authenticated RenewCMS user managing the CMS
|
||||
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
|
||||
@@ -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
|
||||
apperr.ArticleNotFoundErrorMessage, backed by errors.Is(err, article.ErrArticleNotFound),
|
||||
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
|
||||
features/public_article_listing.feature; this slice does not change that behavior,
|
||||
only feeds it
|
||||
@@ -93,6 +96,32 @@ Feature: An authenticated user can publish and unpublish an article
|
||||
| publish |
|
||||
| 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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
|
||||
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"
|
||||
And POST /v1/article/ sits behind the jwtauth Verifier and Authenticator group and
|
||||
requires a valid jwt cookie
|
||||
And UpdateArticleUseCase exposes only UpdateBody, AddImage and UpdateIsOnline; no
|
||||
route or use case updates an article's title after creation, so this feature only
|
||||
concerns article creation
|
||||
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)
|
||||
|
||||
Scenario Outline: CreateArticle now measures title length in runes, not bytes
|
||||
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 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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
14. `POST /v1/article/` with no jwt cookie -> status `401` (regression: the route is
|
||||
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)
|
||||
- None. This slice has no SPA-visible behaviour and no SMTP involvement.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -137,6 +137,9 @@ type Article struct {
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
ContentRevision uint32 `json:"-"`
|
||||
EditRevision uint32 `json:"-"`
|
||||
SourceArticleID *uint32 `json:"-"`
|
||||
SourceRevision uint32 `json:"-"`
|
||||
}
|
||||
|
||||
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 {
|
||||
Title *string
|
||||
IfRevision *uint32
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
@@ -107,4 +109,6 @@ type Repository interface {
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
|
||||
ListByStory(storyID uint32) ([]Article, error)
|
||||
MoveToSite(ids []uint32, siteID uint32) error
|
||||
}
|
||||
@@ -214,6 +214,9 @@ const (
|
||||
FieldLanguages = "languages"
|
||||
FieldDefaultLanguage = "default_language"
|
||||
ParamAssigned = "assigned"
|
||||
ParamLang = "lang"
|
||||
ParamStoryID = "story_id"
|
||||
ParamSourceArticleID = "source_article_id"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -93,3 +93,44 @@ func Plan(current []Row, counts map[string]VersionCount, change Change) ([]Row,
|
||||
}
|
||||
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 (
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -9,7 +10,9 @@ import (
|
||||
const (
|
||||
articleStoriesMarker = "article_stories"
|
||||
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 {
|
||||
@@ -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 {
|
||||
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 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 {
|
||||
return err
|
||||
}
|
||||
if err := BackfillRetiredArticles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureArticleStoryLanguageIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +26,8 @@ const (
|
||||
redactedPassword = "***"
|
||||
mysqlLockNowait = 3572
|
||||
mysqlLockWaitTimeout = 1205
|
||||
mysqlDeadlock = 1213
|
||||
mysqlSnapshotChanged = 1020
|
||||
)
|
||||
|
||||
type mysqlDriver struct{}
|
||||
@@ -88,7 +91,9 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
var mysqlContentionCodes = []uint16{mysqlLockNowait, mysqlLockWaitTimeout, mysqlDeadlock, mysqlSnapshotChanged}
|
||||
|
||||
func (mysqlDriver) IsLockContention(err error) bool {
|
||||
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/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -23,6 +24,8 @@ const (
|
||||
postgresUTCCallback = "renewcms:postgres_utc"
|
||||
gormQueryCallback = "gorm:query"
|
||||
postgresLockNotAvailable = "55P03"
|
||||
postgresDeadlockDetected = "40P01"
|
||||
postgresSerializationFailure = "40001"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var postgresContentionCodes = []string{postgresLockNotAvailable, postgresDeadlockDetected, postgresSerializationFailure}
|
||||
|
||||
func (postgresDriver) IsLockContention(err error) bool {
|
||||
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,
|
||||
StoryID: m.StoryID,
|
||||
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,
|
||||
StoryID: a.StoryID,
|
||||
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:''"`
|
||||
StoryID uint32 `gorm:"not null;default:0"`
|
||||
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"
|
||||
authordomain "RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ArticleRepository struct {
|
||||
@@ -217,11 +222,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
|
||||
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 (
|
||||
idChunkSize = 500
|
||||
initialContentRevision = 1
|
||||
maxUpdateAttempts = 5
|
||||
maxSiteLockRounds = 5
|
||||
)
|
||||
|
||||
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) {
|
||||
created := mappers.ArticleToModel(article)
|
||||
created.ContentRevision = initialContentRevision
|
||||
created := mappers.ArticleToModel(article.Opened())
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if article.SiteID != 0 {
|
||||
if _, err := lockSite(tx, article.SiteID); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -346,6 +354,27 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
|
||||
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 {
|
||||
if created.StoryID != 0 {
|
||||
return nil
|
||||
@@ -408,44 +437,71 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
return a.updateColumns(id, map[string]any{"body": body})
|
||||
}
|
||||
|
||||
localArticle.Body = body
|
||||
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
|
||||
if err != nil {
|
||||
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
|
||||
columns["updated_at"] = time.Now()
|
||||
if err := a.db.Model(&entity.Article{}).Where("id = ?", id).Updates(columns).Error; err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
return a.Get(id)
|
||||
}
|
||||
|
||||
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
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if update.SiteID != nil {
|
||||
if _, err := lockSite(tx, *update.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
if update.SiteID == nil && update.Slug != nil {
|
||||
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
|
||||
locked, err := lockArticleBehindSites(tx, id, update, probedSiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localArticle = locked
|
||||
if update.IfRevision != nil && localArticle.EditRevision != *update.IfRevision {
|
||||
return &domain.RevisionConflictError{Current: localArticle.EditRevision}
|
||||
}
|
||||
before := localArticle
|
||||
applyUpdate(&localArticle, update)
|
||||
if update.Slug != nil || update.SiteID != nil {
|
||||
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
|
||||
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
|
||||
}
|
||||
if update.AuthorIDs != nil {
|
||||
@@ -453,13 +509,97 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, domain.ErrRevisionConflict) || errors.Is(err, errArticleSiteChanged) {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
|
||||
}
|
||||
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", ¤t).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) {
|
||||
setString(&target.Title, update.Title)
|
||||
setString(&target.Body, update.Body)
|
||||
setString(&target.Slug, update.Slug)
|
||||
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) {
|
||||
var localArticle entity.Article
|
||||
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))
|
||||
return a.updateColumns(id, map[string]any{"is_online": isOnline})
|
||||
}
|
||||
|
||||
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 {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
tombstone := domain.Tombstone(id)
|
||||
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil {
|
||||
if found.SiteID != 0 {
|
||||
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
|
||||
}
|
||||
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
|
||||
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/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
@@ -24,6 +26,7 @@ type CreateArticleCommand struct {
|
||||
Title string
|
||||
Body string
|
||||
Format article.BodyFormat
|
||||
Lang string
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
@@ -59,6 +62,11 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
recordFailure(ctx, g.recorder, event, 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()
|
||||
if err != nil {
|
||||
@@ -72,7 +80,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
created, err := g.createWithFreeSlug(ctx, article.Article{
|
||||
created, err := g.createWithFreeSlug(ctx, requested, article.Article{
|
||||
Title: createArticle.Title,
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
@@ -86,14 +94,21 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
event.Target = articleTarget(created)
|
||||
event = articleEvent(audit.ActionArticleCreate, created)
|
||||
g.recorder.Record(ctx, event)
|
||||
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++ {
|
||||
created, err := g.createInTransaction(ctx, draft)
|
||||
created, err := g.createInTransaction(ctx, requested, draft)
|
||||
if !isSlugTakenError(err) {
|
||||
return created, err
|
||||
}
|
||||
@@ -101,13 +116,19 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft art
|
||||
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
|
||||
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
lang, err := tx.Languages.LockStartingLanguage(draft.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requested != language.Unassigned {
|
||||
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
draft.Lang = lang
|
||||
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
@@ -119,6 +140,23 @@ func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft ar
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionArticleDelete, audit.CategoryArticles)
|
||||
event.Target = articleTarget(target)
|
||||
event := articleEvent(audit.ActionArticleDelete, target)
|
||||
if err := g.articleRepository.Delete(id); err != nil {
|
||||
recordFailure(ctx, g.recorder, event, 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/builtintype"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -21,9 +24,13 @@ type UpdateArticleUseCase struct {
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
images image.Repository
|
||||
localization localization.Repository
|
||||
storyMover StoryMover
|
||||
}
|
||||
|
||||
type UpdateArticleCommand struct {
|
||||
Title *string
|
||||
IfRevision *uint32
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
@@ -42,8 +49,8 @@ type UpdateArticleCommand struct {
|
||||
Format article.BodyFormat
|
||||
}
|
||||
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository, images image.Repository) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images}
|
||||
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, localization: localizationRepository, storyMover: storyMover}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return article.Article{}, err
|
||||
}
|
||||
@@ -78,7 +104,28 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
|
||||
return article.Article{}, err
|
||||
}
|
||||
update.Slug = slug
|
||||
if movesWholeStory {
|
||||
return g.updateWithStory(ctx, id, caller, before, command, destination, update, slug)
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
update := article.Update{
|
||||
Title: c.Title,
|
||||
IfRevision: c.IfRevision,
|
||||
Body: c.Body,
|
||||
SiteID: c.SiteID,
|
||||
AuthorIDs: c.AuthorIDs,
|
||||
@@ -188,6 +237,47 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s
|
||||
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 {
|
||||
if ids == nil {
|
||||
return nil
|
||||
@@ -230,8 +320,7 @@ func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Ar
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionArticleEdit, audit.CategoryArticles)
|
||||
event.Target = articleTarget(after)
|
||||
event := articleEvent(audit.ActionArticleEdit, after)
|
||||
event.Changes = changes
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, publishAction(isOnline), before, err)
|
||||
@@ -287,14 +380,11 @@ func (g *UpdateArticleUseCase) recordChange(ctx context.Context, action string,
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(after)
|
||||
event := articleEvent(action, after)
|
||||
event.Changes = changes
|
||||
g.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) recordFailure(ctx context.Context, action string, before article.Article, cause error) {
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(before)
|
||||
recordFailure(ctx, g.recorder, event, cause)
|
||||
recordFailure(ctx, g.recorder, articleEvent(action, before), 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{
|
||||
{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.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.ErrImageTooLarge, "The file is too large to upload."},
|
||||
{image.ErrImageUnreadable, "The image could not be read."},
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ repo="$work/repo"
|
||||
mkdir -p "$repo" "$repo/tmp" "$repo/.git" "$work/tmp" "$root/tmp"
|
||||
|
||||
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=./internal/infrastructure/assets/dist --exclude='*.exe' --exclude='*.db' . |
|
||||
tar -C "$repo" -x
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), 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 {
|
||||
t.Fatal(err)
|
||||
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), 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 {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -68,6 +68,8 @@ var routesNotYetInTheCatalogue = []string{
|
||||
"POST /v1/site/transfer-ownership",
|
||||
"PUT /v1/role/{id}",
|
||||
"PUT /v1/user/{id}/role",
|
||||
"POST /v1/article/{id}/translations",
|
||||
"POST /v1/story/{story_id}/move",
|
||||
}
|
||||
|
||||
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
|
||||
|
||||
@@ -112,7 +112,9 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), 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),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
|
||||
@@ -47,7 +47,9 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -27,7 +27,9 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -39,7 +39,9 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
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.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)
|
||||
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())
|
||||
delete(signedIn, "created_by")
|
||||
delete(signedIn, "capabilities")
|
||||
delete(signedIn, "translations")
|
||||
delete(anonymous, "translations")
|
||||
delete(anonymous, "capabilities")
|
||||
if len(anonymous) != len(signedIn) {
|
||||
t.Fatalf("field sets differ: %d vs %d", len(anonymous), len(signedIn))
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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.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))
|
||||
|
||||
if created.Code != http.StatusCreated || patched.Code != http.StatusOK {
|
||||
t.Fatalf("statuses %d and %d: %s %s", created.Code, patched.Code, created.Body, 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)
|
||||
if patched.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", patched.Code, patched.Body)
|
||||
}
|
||||
assertFields(t, rawFields(decodeArticleFields(t, patched.Body.Bytes())), "fr-CA", legacyArticleKeys)
|
||||
if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory {
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
languageSuggestionsMarker = "site_language_suggestions"
|
||||
storyLanguageIndexName = "idx_articles_story_lang"
|
||||
siteSlugIndexName = "idx_articles_site_slug"
|
||||
retiredIDsMarker = "article_retired_ids"
|
||||
slugPerLanguageMarker = "article_slug_index_per_language"
|
||||
injectedBackfillFailure = "injected backfill failure"
|
||||
raceCreators = 8
|
||||
@@ -73,7 +74,7 @@ func regressToPreLocalizationSchema(t *testing.T, db *gorm.DB) {
|
||||
"UPDATE articles SET story_id = 0, lang = ''",
|
||||
"DELETE FROM stories",
|
||||
"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 {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
|
||||
@@ -27,7 +27,9 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -30,6 +31,12 @@ type mockArticleRepo struct {
|
||||
createAlwaysRaces bool
|
||||
afterFindByBody func()
|
||||
updateBodyIDs []uint32
|
||||
moveFailsAt int
|
||||
beforeCreate func()
|
||||
beforeUpdate func()
|
||||
beforeMove func()
|
||||
onGet func(id uint32) error
|
||||
reverseStory bool
|
||||
}
|
||||
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
@@ -210,6 +217,11 @@ func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if m.onGet != nil {
|
||||
if err := m.onGet(id); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
}
|
||||
a, ok := m.articles[id]
|
||||
if !ok {
|
||||
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) {
|
||||
m.createCalled = true
|
||||
if hook := m.beforeCreate; hook != nil {
|
||||
m.beforeCreate = nil
|
||||
hook()
|
||||
}
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
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) {
|
||||
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++
|
||||
a.ID = m.nextID
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if err := m.fail.trigger("Update"); err != nil {
|
||||
return article.Article{}, err
|
||||
@@ -381,6 +471,18 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
|
||||
if !ok {
|
||||
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 err, failing := m.failUpdateBodyFor[id]; failing {
|
||||
return article.Article{}, err
|
||||
@@ -400,9 +502,13 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
applySeoUpdate(&a, update)
|
||||
authorsChanged := update.AuthorIDs != nil && !slices.Equal(m.credits[id], *update.AuthorIDs)
|
||||
if update.AuthorIDs != nil {
|
||||
m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
|
||||
}
|
||||
if before.EditRevision > 0 {
|
||||
a = a.Revised(article.ImpactOf(before, a, authorsChanged))
|
||||
}
|
||||
m.articles[id] = a
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
|
||||
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -24,7 +24,9 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -360,7 +360,9 @@ func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
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.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
|
||||
c.MergeMediaUseCase = mergeMedia
|
||||
|
||||
@@ -28,7 +28,9 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -32,8 +32,12 @@ func TestDriverLockContention(t *testing.T) {
|
||||
{"sqlite plain error", database.DriverSQLite, errors.New("database is locked"), false},
|
||||
{"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 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},
|
||||
{"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},
|
||||
{"unknown driver", "oracle", codedSQLiteError{5}, false},
|
||||
{"nil error", database.DriverSQLite, nil, false},
|
||||
|
||||
@@ -70,7 +70,9 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), 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),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
|
||||
@@ -130,7 +130,9 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
|
||||
@@ -46,7 +46,9 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -28,7 +28,9 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -234,11 +234,11 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
id uint32
|
||||
allowed bool
|
||||
}{
|
||||
{"update own", updateCall, "content.update", callerWith(21, "content.update=own"), 10, true},
|
||||
{"update foreign", updateCall, "content.update", callerWith(21, "content.update=own"), 11, false},
|
||||
{"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own"), 14, false},
|
||||
{"update all", updateCall, "content.update", callerWith(9, "content.update=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 own", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 10, true},
|
||||
{"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", "content.read=all"), 14, false},
|
||||
{"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.read=all", "content.update:article=none"), 10, false},
|
||||
{"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 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) {
|
||||
repo, sites, authors, images := newRepos()
|
||||
sites.fail.failNext("Get")
|
||||
authors.fail.failNext("Get")
|
||||
images.fail.failNext("Get")
|
||||
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)
|
||||
_, 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")
|
||||
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")
|
||||
@@ -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) {
|
||||
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"
|
||||
_, 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) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
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),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
|
||||
migrateSeo(t, db)
|
||||
|
||||
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) {
|
||||
t.Errorf("found %+v, %v", found, err)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
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.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)
|
||||
@@ -79,7 +81,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
}
|
||||
|
||||
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
|
||||
if id > e.articles.nextID {
|
||||
e.articles.nextID = id
|
||||
|
||||
@@ -2,6 +2,7 @@ package test
|
||||
|
||||
import (
|
||||
articlehandler "RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"math"
|
||||
@@ -59,12 +60,11 @@ func TestSeoOutputListPagingNeverPanics(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSeoOutputContractParity(t *testing.T) {
|
||||
oneof := "oneof=" + strings.Join(article.DismissCodes(), " ")
|
||||
max := "max=" + strconv.Itoa(article.MaxCanonicalURLRunes)
|
||||
for _, subject := range []reflect.Type{reflect.TypeOf(articlehandler.CheckArticleSEOBody{}), reflect.TypeOf(articlehandler.UpdateArticleBody{})} {
|
||||
dismissed, _ := subject.FieldByName("SEODismissed")
|
||||
if !strings.HasSuffix(dismissed.Tag.Get("validate"), oneof) {
|
||||
t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), 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"), validator.SEODismissedTag)
|
||||
}
|
||||
canonical, _ := subject.FieldByName("CanonicalURL")
|
||||
if !strings.Contains(canonical.Tag.Get("validate"), max) {
|
||||
@@ -82,3 +82,17 @@ func TestSeoOutputContractParity(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func TestArticlesBelongToASite(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) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestSiteUseCaseRules(t *testing.T) {
|
||||
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
|
||||
sites := newMockSiteRepo(repo)
|
||||
sites.seed(1, "Main", "main", true)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
missing := uint32(9)
|
||||
|
||||
_, err := uc.Update(testCtx, 1, ownerCaller(1), useCases.UpdateArticleCommand{SiteID: &missing})
|
||||
|
||||
@@ -327,7 +327,9 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
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.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)
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
|
||||
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) {
|
||||
t.Helper()
|
||||
|
||||
@@ -27,7 +27,9 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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(
|
||||
token,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateBody_id1", func(t *testing.T) {
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
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)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
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)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||
|
||||
@@ -25,7 +25,9 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
@@ -217,7 +217,9 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user