feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+5986 -242

No files matched your search

+3 -3
View File
@@ -326,7 +326,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
@@ -359,9 +359,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo),
+170 -13
View File
@@ -6,6 +6,7 @@ import (
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"
@@ -20,6 +21,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
)
type Handler struct {
@@ -47,7 +49,7 @@ func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleU
}
}
type articleResponse struct {
type baseArticleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
@@ -59,12 +61,34 @@ type articleResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
type articleResponse struct {
baseArticleResponse
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
}
type listItemResponse struct {
baseArticleResponse
Excerpt string `json:"excerpt"`
Slug string `json:"slug"`
}
func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArticleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
return baseArticleResponse{}, err
}
body = encoded
}
@@ -72,7 +96,7 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl
if authors == nil {
authors = []author.Byline{}
}
return articleResponse{
return baseArticleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
@@ -85,6 +109,40 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl
}, nil
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
base, err := toBaseResponse(article, format)
if err != nil {
return articleResponse{}, err
}
dismissed := article.SEODismissed
if dismissed == nil {
dismissed = []string{}
}
return articleResponse{
baseArticleResponse: base,
Slug: article.Slug,
Excerpt: article.Excerpt,
SEOTitle: article.SEOTitle,
SEODescription: article.SEODescription,
CoverImageID: article.CoverImageID,
CoverImageAlt: article.CoverImageAlt,
SocialTitle: article.SocialTitle,
SocialDescription: article.SocialDescription,
SocialImageID: article.SocialImageID,
CanonicalURL: article.CanonicalURL,
Noindex: article.Noindex,
SEODismissed: dismissed,
}, nil
}
func toListItemResponse(article domain.Article, format domain.BodyFormat) (listItemResponse, error) {
base, err := toBaseResponse(article, format)
if err != nil {
return listItemResponse{}, err
}
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
if err != nil {
@@ -174,7 +232,9 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return
}
if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to create article", http.StatusInternalServerError)
}
return
}
@@ -182,19 +242,62 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
if !writeKnownArticleError(w, err) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
return true
}
if errors.Is(err, site.ErrSiteNotFound) {
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
return
return true
}
if errors.Is(err, author.ErrAuthorNotFound) {
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, domain.ErrSlugTaken) {
writeSlugTaken(w, err)
return true
}
if errors.Is(err, domain.ErrInvalidSlug) {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return true
}
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
return true
}
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
return true
}
return false
}
func writeSlugTaken(w http.ResponseWriter, err error) {
var conflict *domain.SlugTakenError
if errors.As(err, &conflict) && conflict.Suggestion != "" {
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.ArticleSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
@@ -233,13 +336,50 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
type optionalImageID struct {
Set bool
Value *uint32 `validate:"omitempty,gte=1"`
}
func (o *optionalImageID) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value uint32
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
func (o optionalImageID) toNullable() domain.Nullable[uint32] {
return domain.Nullable[uint32]{Set: o.Set, Value: o.Value}
}
type UpdateArticleBody struct {
Body *string `json:"body"`
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,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"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
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"`
}
var updatableArticleFields = []string{"body", "site_id", "author_ids"}
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 nullableArticleFields = []string{"cover_image_id", "social_image_id"}
func hasDuplicates(ids []uint32) bool {
seen := make(map[uint32]bool, len(ids))
@@ -262,7 +402,7 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
return UpdateArticleBody{}, false
}
known := 0
for _, name := range updatableArticleFields {
for _, name := range nonNullableArticleFields {
value, present := fields[name]
if !present {
continue
@@ -272,6 +412,11 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
}
known++
}
for _, name := range nullableArticleFields {
if _, present := fields[name]; present {
known++
}
}
if known == 0 {
return UpdateArticleBody{}, false
}
@@ -304,7 +449,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeValidationError(w, err)
return
}
@@ -312,6 +457,18 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
Body: request.Body,
SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
Format: format,
})
if err != nil {
@@ -366,7 +523,7 @@ func parseSort(value string) domain.Sort {
}
type listResponse struct {
Items []articleResponse `json:"items"`
Items []listItemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
@@ -435,9 +592,9 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
return
}
responses := make([]articleResponse, 0, len(page.Items))
responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range page.Items {
response, err := toArticleResponse(article, format)
response, err := toListItemResponse(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
+7 -5
View File
@@ -90,9 +90,11 @@ func intQueryParam(values url.Values, name string, fallback int) (int, error) {
}
type usageResponse struct {
Type string `json:"type"`
ID uint32 `json:"id"`
Title string `json:"title"`
Published bool `json:"published"`
Published *bool `json:"published,omitempty"`
Fields []string `json:"fields"`
}
type mergeResponse struct {
@@ -324,15 +326,15 @@ func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
return
}
articles, err := h.UsageUseCase.Usage(id)
usages, err := h.UsageUseCase.Usage(id)
if err != nil {
writeImageError(w, err)
return
}
response := make([]usageResponse, 0, len(articles))
for _, a := range articles {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
response := make([]usageResponse, 0, len(usages))
for _, usage := range usages {
response = append(response, usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields})
}
writeJSON(w, http.StatusOK, response)
}
+14
View File
@@ -35,6 +35,9 @@ type PostSite struct {
Locale *string `json:"locale"`
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
IsDefault bool `json:"is_default"`
SiteName string `json:"site_name" validate:"max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription string `json:"default_description" validate:"max=320"`
}
type optional[T any] struct {
@@ -64,6 +67,9 @@ type PatchSite struct {
Locale optional[string] `json:"locale"`
DefaultSocialImageID optional[uint32] `json:"default_social_image_id"`
IsDefault *bool `json:"is_default"`
SiteName *string `json:"site_name" validate:"omitempty,max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
}
func (p PatchSite) toPatch() domain.Patch {
@@ -75,6 +81,9 @@ func (p PatchSite) toPatch() domain.Patch {
Locale: domain.Nullable[string]{Set: p.Locale.Set, Value: p.Locale.Value},
DefaultSocialImageID: domain.Nullable[uint32]{Set: p.DefaultSocialImageID.Set, Value: p.DefaultSocialImageID.Value},
IsDefault: p.IsDefault,
SiteName: p.SiteName,
TitleTemplate: p.TitleTemplate,
DefaultDescription: p.DefaultDescription,
}
}
@@ -111,6 +120,8 @@ func writeSiteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.SiteKeyImmutableErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSiteURL):
http.Error(w, apperr.InvalidSiteURLErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidTitleTemplate):
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidLocale):
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
@@ -159,6 +170,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
Locale: request.Locale,
DefaultSocialImageID: request.DefaultSocialImageID,
IsDefault: request.IsDefault,
SiteName: request.SiteName,
TitleTemplate: request.TitleTemplate,
DefaultDescription: request.DefaultDescription,
})
if err != nil {
writeSiteError(w, err)
+5
View File
@@ -81,3 +81,8 @@ const InvalidURLErrorMessage = "invalid_url"
const TooManyLinksErrorMessage = "too_many_links"
const InvalidSlugErrorMessage = "invalid_slug"
const AuthorImageNotFoundErrorMessage = "image_not_found"
const ArticleSlugTakenErrorMessage = "slug_taken"
const ArticleImageNotFoundErrorMessage = "image_not_found"
const InvalidTitleTemplateErrorMessage = "invalid_title_template"
const SlugSuggestionHeader = "X-Slug-Suggestion"
+11
View File
@@ -1,6 +1,7 @@
package validator
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
@@ -17,6 +18,7 @@ const siteNameTag = "sitename"
const AuthorNameTag = "authorname"
const AuthorSlugTag = "authorslug"
const SocialTypeTag = "socialtype"
const ArticleSlugTag = "articleslug"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -31,6 +33,15 @@ func InitValidator() {
_ = Validate.RegisterValidation(AuthorNameTag, isAuthorName, true)
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
}
func isArticleSlug(field validator.FieldLevel) bool {
if isAbsent(field) {
return true
}
value := field.Field().String()
return value == "" || article.IsValidSlug(value)
}
func isAbsent(field validator.FieldLevel) bool {
+62 -13
View File
@@ -74,8 +74,8 @@ never changes the response or the change itself.
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
@@ -132,33 +132,82 @@ session." with a key). The role is checked before the body.
| Route | Purpose |
| --- | --- |
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. |
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. |
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
`invalid_locale`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
field that fails validation (`name`, `key` format, `default_robots`) is 400 with the validator text.
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
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` and `site_id` (an
API key may send it too) and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
those fields, a `null` value or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
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
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.
### Article SEO fields
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
kept. `noindex` is only a flag, it does not take the article offline.
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
frees its slug.
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
the free-text fields as a size delta only); a refused slug is recorded as failed.
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles only.
Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
`sites_updated` next to `articles_updated`.
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
`articles_updated`.
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
## Authors
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
@@ -203,8 +252,8 @@ creator's linked author when there is one. `GET /v1/auth/me` returns the existin
`social_links`, `article_count`) or `null`.
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
and keeps the image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles
only. Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
repoint the avatars of the merged copies and report it as `authors_updated`.
## Image variants
+45 -1
View File
@@ -2,7 +2,7 @@
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
`PATCH /v1/article/{id}` takes `body`, `site_id` and `author_ids` (the whole ordered list of author ids, see `docs/api.md`), and nothing else. The request must contain at least one of them, and `null` for either answers `400`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
`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.
## Routes that honour `format`
@@ -71,11 +71,52 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
"is_online": true,
"site_id": 1,
"authors": [],
"slug": "backyard-renovation-update",
"excerpt": "",
"seo_title": "",
"seo_description": "",
"cover_image_id": null,
"cover_image_alt": "",
"social_title": "",
"social_description": "",
"social_image_id": null,
"canonical_url": "",
"noindex": false,
"seo_dismissed": [],
"created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z"
}
```
Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten.
## SEO fields
| Field | Type | Limit and rule |
| --- | --- | --- |
| `slug` | string | `^[a-z0-9]+(-[a-z0-9]+)*$`, 1 to 100 characters, unique per site. `""` regenerates it from the title. |
| `excerpt` | string | 300 characters |
| `seo_title` | string | 120 characters |
| `seo_description` | string | 320 characters |
| `cover_image_id` | number or `null` | an existing image id (`0` is refused) |
| `cover_image_alt` | string | 200 characters, independent of the cover |
| `social_title` | string | 120 characters |
| `social_description` | string | 320 characters |
| `social_image_id` | number or `null` | an existing image id (`0` is refused) |
| `canonical_url` | string | `""` or an absolute `https` URL with a host, at most 2048 characters |
| `noindex` | boolean | only a flag: the article stays online |
| `seo_dismissed` | array of strings | any of `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`; duplicates are collapsed, order kept |
Limits are counted in characters (runes), not bytes. Absent fields are untouched; text fields and `noindex` are cleared with `""`, `false` and `[]`, the two image ids with `null`.
### The slug
A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `<slug>-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing.
Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug.
## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
@@ -129,5 +170,8 @@ Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
| --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code |
| `404` | `Article not found` |
| `409` | `slug_taken`, with the header `X-Slug-Suggestion: <first free slug>` (this response only) |
| `422` | `invalid_url` for a `canonical_url` that is not an absolute `https` URL with a host |
| `500` | `Internal server error` when the body cannot be converted |
+10
View File
@@ -81,6 +81,16 @@ The use cases `ListSites`, `CreateSite`, `UpdateSite`, `SetDefaultSite` and `Del
The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `DeleteAuthor` and `GetMyAuthor` live in `internal/infrastructure/useCases`; `CreateArticle` credits the creator's author, `UpdateArticle` validates and records `author_ids`, and the media, purge and activity use cases take the author repository as a required constructor argument (avatars count as media usage, are cleared on delete and repointed on merge and purge). `api/handlers/author` serves `/v1/author`: the list accepts a session or an API key, every write is session only, `POST /` and `DELETE` sit behind `RequireOwnerOrAdmin`, and the edit rights (owner or admin, or the caller's own linked author) are decided by `UpdateAuthor` from the caller the handler passes in. `GET /v1/auth/me` adds the linked author through `GetMyAuthor`.
### Article SEO data
`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, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. 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.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug, in id order, per site, 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 `|`.
Media usage has more sources than article bodies: `Article.CoverImageID` and `SocialImageID`, `Author.AvatarID` and `Site.DefaultSocialImageID`. `ListMedia`, the purge plan and `GetMediaUsage` count an article once however many places use the image. `DeleteMedia` clears the cover and social references (`article.Repository.ClearImage`, restored with `RestoreImage` when the delete fails), `MergeMedia` repoints them (`RepointImage`) and counts an article rewritten in its body and its cover once, and `GetMediaUsage` (now built with the site and author repositories) returns articles, authors and sites with `type` and `fields`.
### Image variants
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
+25
View File
@@ -15,6 +15,31 @@ Body, in this order. Omit a section only when it would be empty, except Upgradin
Rules: bullets are full sentences ending with a period; user-facing wording, no commit prefixes; sentence-case headings; no horizontal rules, no metadata block, no "Pre-1.0" boilerplate (the prerelease flag says it); every release ships all 6 binaries from `task release`.
## Next release (draft)
Articles and sites now store the data a website needs for search and sharing, and every article gets an address that is unique on its site.
### New
**Article SEO fields**
- An article has a slug, an excerpt, a search title and description, a cover image with its alt text, a social title, description and image, a canonical address, a noindex flag and a list of dismissed suggestions.
- The slug is generated from the title, is unique per site and can be changed at any time; an empty slug regenerates it from the title.
- A site has a site name, a title template and a default description.
### API
- `PATCH /v1/article/{id}` also takes the SEO fields. `cover_image_id` and `social_image_id` accept `null`. A slug already used on the target site answers `409` `slug_taken` with the header `X-Slug-Suggestion` holding the first free slug.
- `GET /v1/article/{id}`, `POST /v1/article/`, `PATCH /v1/article/{id}`, `PATCH /v1/article/{id}/publish` and `PATCH /v1/article/{id}/unpublish` return the twelve SEO fields; the items of `GET /v1/article/` return `slug` and `excerpt`.
- `POST /v1/site/` and `PATCH /v1/site/{id}` take `site_name`, `title_template` and `default_description`; a template without `%title%` is `400` `invalid_title_template`.
- Changed: `GET /v1/media/{id}/usage` stays an array, and every item gains `type` and `fields` without losing `id`, `title` and `published`. Authors and sites that use the image are now listed, without a `published` key.
- Changed: a cover image, a social image, an author avatar and a site default social image all count in `used_in` and in the `unused` filter. Deleting an image clears the covers and social images that use it, and merging or purging repoints them.
### Upgrading
- The first start after the upgrade adds the new columns, gives every existing article a slug generated from its title (in id order within each site, with `-2`, `-3` on conflicts) and creates the unique `(site_id, slug)` index. Nothing else changes, and a second start changes nothing.
- A deleted article now frees its slug.
## Published releases
Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create --tag <tag> --title "<tag> - <theme>" --note-file docs/releases/<tag>.md --prerelease --asset <file>` after `task release`, with one `--asset` per file: the six binaries, `bin/checksums.txt` and `bin/checksums.txt.sig`. `task release` produces all eight and fails before building when the signing key is missing or wrong (see [deployment.md](deployment.md#release-signing-key-maintainers-once)). Never publish a release without the two checksum files.
+2 -1
View File
@@ -84,7 +84,8 @@ Feature: Every real action of the back-office is recorded in the activity log fr
Scenario: A title change is recorded as a text diff when a title edit exists
Given a use case that changes an article's title from "Spring release" to "Spring release 2"
Then its event has action "article.edit" and changes [{"field": "title", "kind": "text", "before": "Spring release", "after": "Spring release 2"}]
And only the fields title, online and body can ever appear in an article event's changes
And only the fields title, online, body and, since features/seo_data_backend.feature, site, authors and the SEO fields can ever appear in an article event's changes
And SEO free-text fields (excerpt, seo_title, seo_description, social_title, social_description, cover_image_alt) are recorded as a size delta in characters with kind "body" and never as text, while short values (slug, canonical_url, noindex, cover_image, social_image) are recorded as text before and after
Scenario: Publishing is recorded with the publish state
Given the article 8 "Spring release" is offline
+3 -2
View File
@@ -757,9 +757,10 @@ Feature: Authors backend, public bylines that articles credit in order
Given the purge of a group fails midway
Then every author avatar_id refers to an image that still exists
Scenario: The usage lookup array shape is unchanged
Scenario: The usage lookup stays an array and later gains additive keys
When a verified account looks up the usage of an image
Then the response keeps the same array shape as before this slice
Then the response is a JSON array whose article items keep id, title and published
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
Rule: Every author write is recorded in the activity log
+1 -1
View File
@@ -79,7 +79,7 @@ Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `te
## I. Media
46. Usage lookup counts an avatar, shape unchanged, image not "Unused". Media list `used_in` sums article, site and author usage.
46. Usage lookup counts an avatar, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), image not "Unused". Media list `used_in` sums article, site and author usage.
47. Deleting the image (204) nulls `avatar_id`.
48. Merge repoints authors and reports `authors_updated`; purge does the same; a failed purge group leaves every `avatar_id` pointing at an existing image.
+784
View File
@@ -0,0 +1,784 @@
Feature: SEO data backend, article and site search fields, unique slugs and media usage
As an editor or the owner of a RenewCMS installation
I want to store search and sharing data on articles and sites, with a slug that is unique per site
So that a frontend can later resolve titles, descriptions, images and addresses from RenewCMS
Background:
Given the spec of record is docs/design/seo-slices.md (slice 1), docs/design/change-list.md ("SEO round"), docs/design/screens/editor.md, docs/design/screens/sites.md and docs/design/states.md
And this slice is backend only: no SPA, no change to docs/design, and no resolver, check endpoint, sitemap or list seo filter
And an article gains these snake_case fields: slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed
And in article JSON the text fields are "" when empty, cover_image_id and social_image_id are null when unset, noindex is false by default and seo_dismissed is [] by default
And every article read (GET /v1/article/{id}, POST /v1/article, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish) carries all twelve fields
And each item of GET /v1/article/ carries excerpt and slug, and none of the other ten new fields
And the SEO fields join body, site_id and author_ids as the known fields of PATCH /v1/article/{id}
And a PATCH with no known field, an unknown field only, or a null value on any field other than cover_image_id and social_image_id answers 400 with apperr.BodyErrorMessage, as before
And limits are counted in runes: excerpt 300, seo_title 120, seo_description 320, cover_image_alt 200, social_title 120, social_description 320, canonical_url 2048
And the slug matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 1 to 100 characters, the rule of authors
And canonical_url is empty or an absolute https URL with a host
And seo_dismissed is a list of the 11 suggestion codes title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short; an unknown code answers 400; duplicates are collapsed with the order kept
And a slug is unique per site (site_id, slug), never globally
And one slug rule applies at creation, in PATCH regeneration and in the migration: lowercase, accents folded, every run of other characters becomes one hyphen, hyphens trimmed, cut to 100 characters with no trailing hyphen, and a result with no letter or digit becomes "article"
And when that slug is taken on the site, creation and the migration add -2, -3 and so on, truncating the base so the result fits 100 characters
And a slug conflict on PATCH follows the authors slug conflict: status 409 with the plain-text body "slug_taken" from http.Error, and additionally the response header X-Slug-Suggestion holds the first free "<slug>-N" from 2 on the article's target site, fitting 100 characters
And other error responses are plain text from http.Error whose body is the existing token or the validator error text: invalid_slug (400, as for authors), invalid_url (422, as for authors), image_not_found (400, as for authors and sites), site_not_found (404), and the new invalid_title_template (400)
And a missing article answers 404 "Article not found" and an offline article still reads as not found publicly
And a site gains site_name, title_template and default_description; site JSON keeps default_social_image_id and default_robots
And POST /v1/site/ and PATCH /v1/site/{id} stay owner only through RequireOwner: a non-owner session gets 403 "Only the owner can do this." and an API key gets 403 "This action needs a real session."
And request validation goes through validate tags and validator.Validate.Struct
And handlers reach articles, sites and media only through use cases and domain interfaces, and no comment is added anywhere
Rule: Article SEO fields are edited through PATCH /v1/article/{id}
Scenario: All SEO fields are set at once
Given the article 8 "Spring release" is on the site 1 and the images 5 and 6 exist
When the user sends PATCH /v1/article/8 with {"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": ["cover_alt", "short"]}
Then the status is 200
And the body carries those values and the unchanged title, body, site_id and authors
And the article is stored with those values
Scenario: Absent fields are untouched
Given the article 8 has an excerpt, a cover image and a canonical_url
When the user sends PATCH /v1/article/8 with {"seo_title": "New title"}
Then only seo_title changed
Scenario Outline: Text fields and the flag are cleared with empty values
Given the article 8 has every SEO field set
When the user sends PATCH /v1/article/8 with <body>
Then the status is 200
And <result>
Examples:
| body | result |
| {"excerpt": ""} | excerpt is "" |
| {"seo_title": ""} | seo_title is "" |
| {"seo_description": ""} | seo_description is "" |
| {"cover_image_alt": ""} | cover_image_alt is "" |
| {"social_title": ""} | social_title is "" |
| {"social_description": ""} | social_description is "" |
| {"canonical_url": ""} | canonical_url is "" |
| {"noindex": false} | noindex is false |
| {"seo_dismissed": []} | seo_dismissed is [] |
| {"cover_image_id": null} | cover_image_id is null |
| {"social_image_id": null} | social_image_id is null |
Scenario Outline: Boundaries are accepted
When the user sends PATCH /v1/article/8 with <field> <value>
Then the status is 200
Examples:
| field | value |
| excerpt | 300 characters |
| excerpt | 300 multi-byte characters |
| seo_title | 120 characters |
| seo_description | 320 characters |
| cover_image_alt | 200 characters |
| social_title | 120 characters |
| social_description | 320 characters |
| slug | 100 characters |
| canonical_url | "https://example.com/a?b=1#c" |
| canonical_url | 2048 characters, https |
Scenario Outline: Fields are validated
When the user sends PATCH /v1/article/8 with <field> <value>
Then the status is <status>
And the body is "<body>"
And the article is unchanged
Examples:
| field | value | status | body |
| excerpt | 301 characters | 400 | the validator error text |
| seo_title | 121 characters | 400 | the validator error text |
| seo_description | 321 characters | 400 | the validator error text |
| cover_image_alt | 201 characters | 400 | the validator error text |
| social_title | 121 characters | 400 | the validator error text |
| social_description | 321 characters | 400 | the validator error text |
| canonical_url | 2049 characters, https | 400 | the validator error text |
| slug | "Spring" | 400 | invalid_slug |
| slug | "spring_2026" | 400 | invalid_slug |
| slug | "-spring" | 400 | invalid_slug |
| slug | "spring--2026" | 400 | invalid_slug |
| slug | 101 characters | 400 | invalid_slug |
| canonical_url | "http://example.com/a" | 422 | invalid_url |
| canonical_url | "/relative" | 422 | invalid_url |
| canonical_url | "example.com" | 422 | invalid_url |
| canonical_url | "https://" | 422 | invalid_url |
| seo_dismissed | ["nope"] | 400 | the validator error text |
| cover_image_id | 999 (no such image) | 400 | image_not_found |
| social_image_id | 999 (no such image) | 400 | image_not_found |
| cover_image_id | 0 | 400 | the validator error text |
Scenario Outline: A wrong type or null on other fields is refused
When the user sends PATCH /v1/article/8 with <body>
Then the status is 400
And the body is apperr.BodyErrorMessage
And the article is unchanged
Examples:
| body |
| {} |
| {"colour": "x"} |
| {"slug": null} |
| {"excerpt": null} |
| {"noindex": null} |
| {"seo_dismissed": null} |
| {"noindex": "yes"} |
| {"excerpt": 5} |
| {"cover_image_id": "a"} |
| {"seo_dismissed": "x"} |
Scenario: Duplicate dismissed codes are collapsed
When the user sends PATCH /v1/article/8 with {"seo_dismissed": ["short", "cover_alt", "short"]}
Then seo_dismissed is ["short", "cover_alt"]
Scenario: Every known dismissed code is accepted
When the user sends PATCH /v1/article/8 with all 11 codes in seo_dismissed
Then the status is 200
Scenario: A refused update changes nothing else
When the user sends PATCH /v1/article/8 with {"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}
Then the status is 422 with body "invalid_url"
And the body and the excerpt of the article 8 are unchanged
Scenario: SEO fields and the other fields change together
When the user sends PATCH /v1/article/8 with {"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}
Then the body, the site_id, the authors and the excerpt are all updated
Scenario: An update without SEO fields keeps them
Given the article 8 has every SEO field set
When the user sends PATCH /v1/article/8 with {"body": "New"}
Then every SEO field is unchanged
Scenario Outline: Any account that can edit the article edits its SEO fields
When PATCH /v1/article/8 is sent with {"excerpt": "Teaser"} and <credentials>
Then the status is 200
Examples:
| credentials |
| the owner's jwt cookie |
| a verified admin's jwt cookie |
| a verified account with another role |
| a valid API key |
Scenario Outline: Without a verified account the article is not edited
When PATCH /v1/article/8 is sent with {"excerpt": "Teaser"} and <credentials>
Then the status is <status>
And the article is unchanged
Examples:
| credentials | status |
| an unverified account's jwt cookie | 403 |
| no credentials | 401 |
Scenario: A missing article is not found
When the user sends PATCH /v1/article/999 with {"excerpt": "x"}
Then the status is 404 with body "Article not found"
Scenario: A malformed id is refused
When the user sends PATCH /v1/article/abc with {"excerpt": "x"}
Then the status is 400 with body apperr.IdUint32ErrorMessage
Scenario: noindex is only a flag
Given the article 8 is online
When the user sends PATCH /v1/article/8 with {"noindex": true}
Then the article is still online
And an anonymous GET /v1/article/8 still returns it with "noindex": true
Scenario: The cover alt is independent of the cover
When the user sends PATCH /v1/article/8 with {"cover_image_alt": "A field"} on an article without cover
Then the status is 200
And cover_image_id is null and cover_image_alt is "A field"
Rule: Article reads carry the SEO fields
Scenario Outline: Single-article reads carry all twelve fields
Given the article 8 has every SEO field set
When <request> is sent
Then the article in the response has slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex and seo_dismissed with the stored values
Examples:
| request |
| GET /v1/article/8 |
| PATCH /v1/article/8 |
| PATCH /v1/article/8/publish |
| PATCH /v1/article/8/unpublish |
Scenario: A new article has the defaults
When the user sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
Then the status is 201
And the article has slug "spring-release", excerpt "", seo_title "", seo_description "", cover_image_id null, cover_image_alt "", social_title "", social_description "", social_image_id null, canonical_url "", noindex false and seo_dismissed []
Scenario: The list carries the excerpt and the slug
Given the article 8 has the excerpt "Short teaser." and the slug "spring-release"
When a verified account sends GET /v1/article/
Then each item of items has "excerpt" and "slug"
And the article 8 item has excerpt "Short teaser." and slug "spring-release"
And no item has seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex or seo_dismissed
And the status, total, page, size and counts are as before this slice
Scenario: Anonymous readers see the SEO fields of online articles
Given the article 9 is online with SEO fields set
When a caller without credentials sends GET /v1/article/9
Then the SEO fields are returned
Scenario: An offline article is still not found publicly
When a caller without a verified account sends GET /v1/article/{offline id}
Then the status is 404 with body "Article not found"
Scenario: The list for anonymous callers shows online articles only
Given one online and one offline article
When a caller without a verified account sends GET /v1/article/
Then only the online article is listed, with its excerpt and slug
Rule: The slug is unique per site and generated at creation
Scenario Outline: The slug is generated from the title at creation
When the user sends POST /v1/article with {"title": "<title>", "body": "x"}
Then the slug is "<slug>"
Examples:
| title | slug |
| Spring release | spring-release |
| Été à Paris | ete-a-paris |
| Dr. O'Neil Smith | dr-o-neil-smith |
| !!! | article |
Scenario: A long title gives a slug of at most 100 characters
When the user sends POST /v1/article with a title whose slug would exceed 100 characters
Then the slug has at most 100 characters and does not end with "-"
Scenario: A generated slug that is taken on the site is de-duplicated silently
Given the site 1 has the articles with slugs "spring-release" and "spring-release-2"
When the user sends POST /v1/article with {"title": "Spring release", "body": "x"}
Then the status is 201
And the slug is "spring-release-3"
Scenario: A title without letters or digits is de-duplicated too
Given the site 1 has an article with slug "article"
When the user sends POST /v1/article with {"title": "!!!", "body": "x"}
Then the slug is "article-2"
Scenario: The suffix still fits the slug limit at creation
Given a title whose slug is 100 characters and that slug is taken on the site
When the user creates the article
Then the slug is at most 100 characters and ends with "-2"
Scenario: The same title on another site keeps the plain slug
Given the default site 1 has an article with slug "spring-release"
And the site 2 has no such slug
When the article is created on the site 2
Then the slug is "spring-release"
Scenario: A new article is created on the default site, so uniqueness uses that site
Given the default site 2 has an article with slug "spring-release"
When the user creates "Spring release" without a site
Then the slug is "spring-release-2"
Scenario: The same slug is allowed on two sites
Given the article 8 on the site 1 has slug "spring"
And the article 9 is on the site 2
When the user sends PATCH /v1/article/9 with {"slug": "spring"}
Then the status is 200
Scenario: The slug is editable at any time
Given the article 8 is online with slug "spring-release"
When the user sends PATCH /v1/article/8 with {"slug": "spring-2026"}
Then the status is 200
And the slug is "spring-2026" and the article is still online
Scenario: Keeping the own slug is not a collision
When the user sends PATCH /v1/article/8 with {"slug": "spring-release", "excerpt": "x"} and the slug is already that article's
Then the status is 200
Scenario Outline: A taken slug is refused with a suggestion
Given the site 1 holds the articles 8 and 9 and the slugs <taken> are used by other articles
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
Then the status is 409
And the body is "slug_taken"
And the header X-Slug-Suggestion is "<suggestion>"
And the article 8 is unchanged
Examples:
| taken | suggestion |
| "spring" | spring-2 |
| "spring", "spring-2" | spring-3 |
| "spring", "spring-2", "spring-3" | spring-4 |
Scenario: The suggestion fits the slug limit
Given another article has a 100-character slug and the article 8 asks for it
Then the 409 suggestion is at most 100 characters and ends with "-2"
Scenario: The suggestion ignores the article itself
Given the article 8 has slug "spring-2" and the article 9 has "spring"
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
Then the status is 409 with suggestion "spring-2"
Scenario: The repository can report a race
Given the repository reports a unique violation on (site_id, slug)
When the user sends PATCH /v1/article/8 with a free-looking slug
Then the status is 409 with body "slug_taken" and a suggestion header "<slug>-2"
And the article is unchanged
Scenario: An empty slug regenerates it from the title
Given the article 8 "Spring release" has slug "custom-address"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the status is 200
And the slug is "spring-release"
Scenario: Regeneration follows the creation rule
Given the article 8 titled "!!!" has slug "custom"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the slug is "article"
Scenario: Regenerating to the slug it already has is a no-op
Given the article 8 "Spring release" already has slug "spring-release"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the status is 200 and no activity event is recorded
Scenario: Regeneration that collides is refused, never silently suffixed
Given the article 9 on the same site has slug "spring-release" and the article 8 "Spring release" has slug "custom"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the status is 409
And the body is "slug_taken"
And the header X-Slug-Suggestion is "spring-release-2"
And the slug of the article 8 is still "custom"
Scenario: Regeneration combined with a move uses the target site
Given the article 8 "Spring release" is on the site 1 and the site 2 holds another article with slug "spring-release"
When the user sends PATCH /v1/article/8 with {"site_id": 2, "slug": ""}
Then the status is 409 with suggestion "spring-release-2"
Scenario: Moving to another site re-checks the slug on the target
Given the article 8 on the site 1 has slug "spring" and the site 2 has an article with slug "spring"
When the user sends PATCH /v1/article/8 with {"site_id": 2}
Then the status is 409
And the body is "slug_taken" with the header X-Slug-Suggestion "spring-2"
And the article 8 is still on the site 1 with slug "spring"
Scenario: Moving to a site where the slug is free keeps the slug
When the user sends PATCH /v1/article/8 with {"site_id": 2} and the slug is free on the site 2
Then the status is 200
And the slug is unchanged
Scenario: Moving and changing the slug together checks the new slug on the target
Given the site 2 has an article with slug "spring" and none with "autumn"
When the user sends PATCH /v1/article/8 with {"site_id": 2, "slug": "autumn"}
Then the status is 200
When the user sends PATCH /v1/article/9 with {"site_id": 2, "slug": "spring"} on another article
Then the status is 409 with suggestion "spring-2"
Scenario: A refused move changes no SEO field
When the user sends PATCH /v1/article/8 with {"site_id": 2, "excerpt": "new"} and the slug collides on the site 2
Then the status is 409
And the excerpt, the site_id and every other field of the article 8 are unchanged
Scenario: A freed slug can be reused
Given the article 9 has slug "spring" and is deleted
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
Then the status is 200
Scenario: Publishing and unpublishing never change the slug
When the article 8 is published then unpublished
Then its slug is unchanged
Scenario: Slug uniqueness is enforced by the database
When the repository contract suite runs
Then a second article with the same (site_id, slug) is refused by every driver
Rule: Site SEO fields are edited by the owner only
Scenario: The owner sets the site SEO fields
Given the site 2 "Shop"
When the owner sends PATCH /v1/site/2 with {"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}
Then the status is 200
And the body is the site with those three values and its other fields unchanged
Scenario: A site created without the new fields has the defaults
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}
Then the site has site_name "", title_template "%title% | %site%" and default_description ""
Scenario: A site is created with the new fields
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}
Then the status is 201
And the body carries those three values
And the site is stored with them
Scenario Outline: Creation validates the new fields like editing
When the owner sends POST /v1/site/ with a valid site and <field> <value>
Then the status is <status>
And the body is "<body>"
And no site is added when the status is not 201
Examples:
| field | value | status | body |
| title_template | "%title%" | 201 | |
| title_template | "just text" | 400 | invalid_title_template |
| title_template | "%site%" | 400 | invalid_title_template |
| title_template | "" | 400 | invalid_title_template |
| title_template | 201 characters | 400 | the validator error text |
| default_description | 320 characters | 201 | |
| default_description | 321 characters | 400 | the validator error text |
| site_name | 100 characters | 201 | |
| site_name | 101 characters | 400 | the validator error text |
Scenario: The list carries the new fields
When a verified account sends GET /v1/site/
Then every site has site_name, title_template and default_description
Scenario: Absent fields are untouched
Given the site 2 has a custom title_template and default_description
When the owner sends PATCH /v1/site/2 with {"site_name": "Renew"}
Then only site_name changed
Scenario Outline: The template must contain %title%
When the owner sends PATCH /v1/site/2 with {"title_template": <template>}
Then the status is <status>
And the body is "<body>"
Examples:
| template | status | body |
| "%title%" | 200 | |
| "%title% \| %site%" | 200 | |
| "%site% - %title%" | 200 | |
| "News: %title%" | 200 | |
| "%site%" | 400 | invalid_title_template |
| "just text" | 400 | invalid_title_template |
| "%TITLE%" | 400 | invalid_title_template |
| "" | 400 | invalid_title_template |
| 201 characters | 400 | the validator error text |
Scenario Outline: Other site SEO fields are validated
When the owner sends PATCH /v1/site/2 with <field> <value>
Then the status is <status>
And the body is "<body>"
Examples:
| field | value | status | body |
| default_description | 321 characters | 400 | the validator error text |
| default_description | 320 characters | 200 | |
| default_description | "" | 200 | |
| site_name | 101 characters | 400 | the validator error text |
| site_name | "" | 200 | |
Scenario: A refused site update changes no other field
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "title_template": "nope"}
Then the status is 400 with body "invalid_title_template"
And the name of the site 2 is unchanged
Scenario: The existing site fields keep their names
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": 5, "default_robots": "noindex"}
Then the status is 200 and the site carries default_social_image_id 5 and default_robots "noindex"
Scenario Outline: Other roles and keys are refused
When <request> is sent with <credentials>
Then the status is 403
And the body is "<body>"
And no site changed
Examples:
| request | credentials | body |
| PATCH /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
| PATCH /v1/site/2 | a verified account with another role | Only the owner can do this. |
| PATCH /v1/site/2 | a valid API key | This action needs a real session. |
| POST /v1/site/ | a verified admin's jwt cookie | Only the owner can do this. |
| POST /v1/site/ | a valid API key | This action needs a real session. |
Scenario: No credentials is unauthorised
When PATCH /v1/site/2 is sent with no credentials
Then the status is 401
Scenario: The role is checked before the body
When an admin sends PATCH /v1/site/2 with the body "{not json"
Then the status is 403 with body "Only the owner can do this."
Scenario: A no-op site edit records nothing
When the owner sends PATCH /v1/site/2 with the SEO values it already has
Then the status is 200
And no activity event is recorded
Rule: Existing articles are backfilled with a unique slug
Scenario: Every existing article gets a slug from its title
Given a database from before this slice holding the articles "Spring release" (id 1) and "Autumn notes" (id 2) on the site 1
When database.Migrate runs
Then the articles have slugs "spring-release" and "autumn-notes"
Scenario: Conflicts get suffixes in id order within a site
Given the site 1 holds the articles 1, 2 and 3 all titled "News"
When database.Migrate runs
Then their slugs are "news", "news-2" and "news-3" in id order
Scenario: A suffix skips slugs that exist
Given the site 1 holds the articles 1 "News", 2 "News 2" and 3 "News"
When database.Migrate runs
Then every slug of the site 1 is distinct and the article 1 has "news"
Scenario: Slugs are unique per site, not globally
Given the article 1 on the site 1 and the article 2 on the site 2 are both titled "News"
When database.Migrate runs
Then both slugs are "news"
Scenario Outline: A title with no letter or digit becomes article, suffixed on conflict
Given the site 1 holds the articles 5 titled "<first>", 6 titled "<second>" and 7 titled "<third>"
When database.Migrate runs
Then their slugs are "article", "article-2" and "article-3" in id order
And no slug is of the form "article-<id>" unless it comes from the suffix rule
Examples:
| first | second | third |
| !!! | ??? | ... |
Scenario: Long titles stay within 100 characters
Given two articles on the same site whose titles give a slug longer than 100 characters
When database.Migrate runs
Then both slugs have at most 100 characters, are distinct and match the slug rule
Scenario: The migration is idempotent
Given the migration already ran
When database.Migrate runs again
Then no slug changes
Scenario: An edited slug survives a restart
Given the owner changed the slug of the article 1 to "custom"
When database.Migrate runs again
Then the article 1 keeps "custom"
Scenario: New site columns get defaults
Given a database holding the sites from before this slice
When database.Migrate runs
Then every site has site_name "", title_template "%title% | %site%" and default_description ""
Scenario: Existing articles get neutral SEO defaults
When database.Migrate runs on articles from before this slice
Then excerpt, seo_title, seo_description, cover_image_alt, social_title, social_description and canonical_url are "", the image ids are null, noindex is false and seo_dismissed is []
Scenario: A database with a partially backfilled table completes
Given some articles have a slug and some are empty
When database.Migrate runs
Then the empty ones get unique slugs and the existing ones are unchanged
Scenario: The migration creates the per-site unique index
When database.Migrate runs
Then a unique index on (site_id, slug) exists on articles
Scenario: It works on every database driver
When the repository contract suite runs
Then the article and site repositories behave the same on SQLite, MySQL and PostgreSQL
And the SQLite schema test accounts for the new columns and the index
Rule: Cover, social and site images count as media usages
Scenario: A cover or social image counts as used
Given the image 5 is the cover of the article 8 and appears in no article body, author or site
When a verified account looks up the usage of the image 5
Then the usage lists the article 8 and the image is not "Unused"
Scenario Outline: Each reference kind counts
Given the image 5 is referenced only as <reference>
When a verified account sends GET /v1/media/?filter=unused
Then the image 5 is <listed>
Examples:
| reference | listed |
| the cover of an article | not in the list |
| the social image of an article | not in the list |
| the default social image of a site | not in the list |
| the avatar of an author | not in the list |
| a file name in an article body | not in the list |
| nothing | in the list |
Scenario: An article that uses the image twice counts once
Given the image 5 is both in the body and the cover of the article 8
Then used_in of the image 5 is 1 for that article
Scenario: The media list sums every kind
Given the image 5 is the cover of an article, the social image of another, in a third article body, the default social image of a site and the avatar of an author
When a verified account sends GET /v1/media/
Then the used_in of the image 5 is 5
Scenario: Usage lists articles, authors and sites
Given the image 5 is in the body of the article 3, the cover of the article 8, the avatar of the author 2 and the default social image of the site 1
When a verified account sends GET /v1/media/5/usage
Then the status is 200
And the body is a JSON array of 4 items: {"type": "article", "id": 3, "title": "...", "published": ..., "fields": ["body"]}, {"type": "article", "id": 8, "title": "...", "published": ..., "fields": ["cover"]}, {"type": "author", "id": 2, "title": "<author name>", "fields": ["avatar"]} and {"type": "site", "id": 1, "title": "<site name>", "fields": ["default_social_image"]}
And author and site items carry no published key
Scenario: The change is additive for existing consumers
Given the image 5 is in the body of the article 3
When a verified account sends GET /v1/media/5/usage
Then the item still has id, title and published with the same values as before this slice
And the only new keys are type and fields
Scenario: An article using an image in several places is one item with all fields
Given the image 5 is the cover, the social image and in the body of the article 8
When a verified account sends GET /v1/media/5/usage
Then there is one item for the article 8 with fields ["body", "cover", "social"]
Scenario: An unused image has an empty usage array
When a verified account sends GET /v1/media/9/usage on an unused image
Then the status is 200 and the body is []
Scenario: An unknown image is not found
When a verified account sends GET /v1/media/999/usage
Then the status is 404 with the existing image-not-found body
Scenario: Offline articles are listed with published false
Given the image 5 is the cover of an offline article
When a verified account looks up the usage of the image 5
Then the item has "published": false
Scenario: Deleting an image in use clears the references and is never refused
Given the image 5 is the cover of the article 8 and the social image of the article 9, with cover_image_alt "A field"
When the owner deletes the image 5
Then the status is 204
And the article 8 has cover_image_id null and the article 9 has social_image_id null
And the cover_image_alt of the article 8 is unchanged
And the article bodies are untouched
And the avatar and site references to the image 5 are cleared as before
Scenario: A failed delete leaves every reference in place
Given the repository fails while removing the image 5
When the owner deletes the image 5
Then the cover, social, avatar and site references to the image 5 are all unchanged
Scenario: Merging copies repoints article cover and social images
Given the image 6 is a copy of the image 5 and the article 8 has the cover 6 and the article 9 the social image 6
When the copies are merged into the image 5
Then the article 8 has cover_image_id 5 and the article 9 has social_image_id 5
And the merge response counts both articles in articles_updated
And updated_article_ids lists both
Scenario: An article rewritten in body and cover counts once
Given the article 8 has the copy 6 in its body and as its cover
When the copies are merged into the image 5
Then articles_updated is 1 and the article 8 has the image 5 in both places
Scenario: Purge repoints articles the same way
Given a purge group whose kept image is 5 and whose duplicate 6 is the cover of the article 8
When the purge runs
Then the article 8 has cover_image_id 5
And the purge result counts it in articles_updated
Scenario: A failed purge group leaves covers pointing at existing images
Given the purge of a group fails midway
Then every article cover_image_id and social_image_id refers to an image that still exists
Scenario: Authors and sites keep being repointed
Given the duplicate 6 is used by the author 2 and the site 1
When the copies are merged into the image 5
Then authors_updated is 1 and sites_updated is 1
Scenario: Cover and social usage counts in the purge preview
Given a duplicate group whose only usage is as article covers
When the purge preview runs
Then the kept image is the one in use, and used_in includes cover and social usages
Rule: SEO edits are recorded in the activity log without leaking the body
Scenario Outline: A short-value SEO edit is recorded once after the change
Given the article 8 "Spring release" has <before>
When the user sends PATCH /v1/article/8 with <body>
Then one event exists with action "article.edit", category "articles", target {"type": "article", "id": "8", "name": "Spring release"}, outcome "success" and source "web"
And its changes are [{"field": "<field>", "kind": "text", "before": "<from>", "after": "<to>"}]
And the event was recorded after the change was applied
Examples:
| before | body | field | from | to |
| slug "spring" | {"slug": "spring-2026"} | slug | spring | spring-2026 |
| canonical_url "" | {"canonical_url": "https://www.example.com/a"} | canonical_url | (empty) | https://www.example.com/a |
| noindex false | {"noindex": true} | noindex | false | true |
| cover_image_id null | {"cover_image_id": 5} | cover_image | (empty) | the file name of the image 5 |
| social_image_id 5 | {"social_image_id": null} | social_image | the file name of the image 5 | (empty) |
Scenario: A regenerated slug is recorded like an edited one
Given the article 8 "Spring release" has slug "custom"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then one event "article.edit" exists with changes [{"field": "slug", "kind": "text", "before": "custom", "after": "spring-release"}]
Scenario Outline: Free-text SEO fields are recorded as a size change, never as text
Given the article 8 has <field> of <before> characters
When the user sends PATCH /v1/article/8 with <field> of <after> characters
Then one event "article.edit" exists with changes [{"field": "<field>", "kind": "body", "delta": <delta>}]
And no stored field and no /v1/activity response contains any text of either value
Examples:
| field | before | after | delta |
| excerpt | 0 | 120 | 120 |
| seo_title | 40 | 55 | 15 |
| seo_description | 160 | 100 | -60 |
| social_title | 0 | 30 | 30 |
| cover_image_alt | 0 | 16 | 16 |
Scenario: Same-length free text with different content is still a change
Given the article 8 has seo_title "AAAAA"
When the user sends PATCH /v1/article/8 with {"seo_title": "BBBBB"}
Then one event "article.edit" exists with the change on seo_title and delta 0
And no event or response contains "AAAAA" or "BBBBB"
Scenario: A body and SEO edit together record both
When the user sends PATCH /v1/article/8 with {"body": "New body text", "slug": "new"}
Then one event "article.edit" exists with changes for body (kind "body", delta) and slug (kind "text")
And the event holds no text of the body
Scenario: Dismissing suggestions is not an edit
When the user sends PATCH /v1/article/8 with {"seo_dismissed": ["short"]}
Then the status is 200
And no activity event is recorded
Scenario: A no-op SEO edit records nothing
When the user sends PATCH /v1/article/8 with the SEO values it already has
Then the status is 200
And no activity event is recorded
Scenario: A refused slug is recorded as failed with a plain reason
When the user sends PATCH /v1/article/8 with a taken slug
Then one event exists with action "article.edit", outcome "failed" and failure_reason "That slug is already used by another article on this site."
Scenario Outline: Nothing is recorded when there is nothing to attribute
When <request> is sent
Then no activity event is recorded
Examples:
| request |
| PATCH /v1/article/999 with an SEO field |
| PATCH /v1/article/abc with an SEO field |
| PATCH /v1/article/8 with {"slug": "Bad Slug"} |
| PATCH /v1/article/8 with a 301-character excerpt |
| PATCH /v1/article/8 with an unknown cover image |
| PATCH /v1/article/8 with no credentials (401) |
Scenario: A failing audit repository never changes the response
Given the audit repository fails on Append
When the user sends PATCH /v1/article/8 with {"slug": "new"}
Then the response equals that of a healthy repository
And the slug was changed
Scenario Outline: A site SEO edit is recorded
When the owner sends PATCH /v1/site/2 with <body>
Then one event "site.edit" exists with category "sites" and changes [{"field": "<field>", "kind": "text", "before": "<from>", "after": "<to>"}]
Examples:
| body | field | from | to |
| {"site_name": "Renew"} | site_name | (empty) | Renew |
| {"title_template": "%title% - %site%"} | title_template | %title% \| %site% | %title% - %site% |
| {"default_description": "A shop."} | default_description | (empty) | A shop. |
Scenario: The activity API lists the SEO events
Given article and site SEO events were recorded
When a verified account sends GET /v1/activity/?category=articles
Then the article.edit events are returned
Rule: Layering and documentation
Scenario: Layering is respected
Then use cases import no persistence, mailer, models, gorm, net/http or chi
And the article and site domain packages import no GORM or HTTP
And handlers import no persistence
And only api/dependecyInjection.go builds the repositories and use cases, including the widened GetMediaUsageUseCase
Scenario: The docs describe the new surface
Then docs/api.md documents the new article and site fields, the 409 slug_taken response with its X-Slug-Suggestion header, the empty-slug regeneration, the new error tokens, the widened GET /v1/media/{id}/usage items and the new used_in sources
And docs/api/articles.md documents the twelve article fields, their limits and the per-site slug rule
And docs/architecture.md mentions the per-site slug uniqueness, the backfill and the media usage sources
And docs/release-notes.md announces the change, including the additive usage response keys
And web/src/data/apiCatalogue.ts lists the new PATCH request fields and its spec checks them
+91
View File
@@ -0,0 +1,91 @@
# QA procedure: SEO data backend (slice 1 of 4)
Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. The 409 `slug_taken` assertion checks the plain-text body `slug_taken` and the `X-Slug-Suggestion` header.
## Divergences from docs/design
`docs/design` is read-only; the SPA slices (3 and 4) align to the code, not to these design words.
- **Site image and robots names:** the API uses `default_social_image_id` and `default_robots` (existing site contract). The design writes `social_image_id` for the site and, in places, `robots`. The SPA uses the code names.
- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.OwnerRequiredErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist.
- **Empty-slug fallback:** one rule everywhere. A title with no letter or digit becomes `article`, then `article-2`, `article-3` on conflict, in the API and in the migration. `docs/design/seo-slices.md` (decision 5) says `article-<id>` for the migration; that wording is not followed.
- **Slug conflict transport:** the conflict follows the authors slug conflict (plain text `slug_taken`, 409); the suggestion travels in the `X-Slug-Suggestion` response header, not in a JSON body. The `slug.suggestion` of the slice 2 check endpoint is separate.
- **Empty slug:** `slug: ""` regenerates the slug from the title (the SPA "Reset to match the title" sends `""`); 409 only if the regenerated slug belongs to another article on the site.
- **Status codes:** `invalid_url` is 422 (authors); `invalid_slug`, `invalid_title_template` and `image_not_found` are 400.
- **Usage response:** `GET /media/{id}/usage` stays an array; items gain `type` and `fields`; authors and sites are listed; non-article items have no `published`.
## Setup
- New files `test/seo_data_*_test.go`. Fakes have unique names: `mockSeoArticleRepo` (articles with per-site unique slug, unique-violation injection on (site_id, slug), `FindByBodyContaining`, cover and social references, repoint helpers), `mockSeoSiteRepo`, `mockSeoAuthorRepo`, `mockSeoImageRepo` (images, delete, merge and purge repointing, failure injection), and a fake audit repository with fail and panic switches (reuse).
- Routers mirror `api/router.go`: article, site, media (including `/{id}/usage`) and activity routers. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Signed `jwt` cookies with the `user_id` claim for: owner, admin, a verified account whose role is `"editor"`, an unverified account. An API-key request uses the key authentication path.
- Helpers create articles, sites, images, authors and references directly in the fakes. Assertions read the fakes, never only the JSON.
## A. Article PATCH, SEO fields
1. Set all twelve fields: 200, body and stored values equal, unrelated fields unchanged.
2. Absent fields untouched; clear table (`""`, `false`, `[]`, `null` on the two image ids).
3. Boundary table: 300, 120, 320, 200, 120, 320, slug 100, canonical 2048; ASCII and multi-byte counted in runes; a canonical URL with query and fragment.
4. Validation table with exact status and token: over-limit fields give 400 validator text; slug cases (`Spring`, `spring_2026`, `-spring`, `spring--2026`, 101 chars) give 400 `invalid_slug`; canonical non-https, relative, no host give 422 `invalid_url`; unknown dismissed code gives 400 validator text; unknown image ids give 400 `image_not_found`; image id `0` gives the validator text. Article unchanged each time.
5. Null and wrong-type table (`{}`, unknown, `null` on slug, excerpt, noindex and `seo_dismissed`, wrong types) give 400 `apperr.BodyErrorMessage`; `null` on the two image ids gives 200.
6. Duplicate dismissed codes collapse, order kept; all 11 codes accepted. A refused update changes no other field. Combined PATCH of body, site, authors and SEO.
7. Callers: owner, admin, "editor" and API key give 200; unverified gives 403; none gives 401. Missing article gives 404 "Article not found"; `abc` gives `apperr.IdUint32ErrorMessage`.
8. `noindex` leaves `is_online` unchanged. `cover_image_alt` is accepted without a cover.
## B. Article reads
9. GET one, PATCH, publish and unpublish carry the twelve fields with stored values; POST returns the generated slug and the other defaults.
10. List items carry `excerpt` and `slug` and none of the other ten new fields (key absence check); `total`, `page`, `size` and `counts` unchanged. Anonymous and unverified readers get online articles only, and an offline article is 404 "Article not found".
## C. Slug
11. Generation table (accents, punctuation runs, `!!!` gives `article`, 100-character cap with no trailing hyphen).
12. Creation de-duplication: `spring-release` and `-2` taken gives `-3`; `article` taken gives `article-2`; suffix fits 100; same title on another site keeps the plain slug; default-site uniqueness.
13. Same slug on two sites via PATCH is 200. Editable when online. Own slug re-sent is 200.
14. 409 table: `spring`, `spring-2`, `spring-3` taken give header suggestions `spring-2`, `-3`, `-4`; body is exactly `slug_taken`; 100-character suggestion fits; the suggestion ignores the article itself; repository unique-violation injection gives the same 409; the article is unchanged.
15. Empty slug: regenerates from the title (200); follows the creation rule (`!!!` gives `article`); already equal is a 200 with no event; collision gives 409 `slug_taken` with the suggestion header and leaves the slug unchanged (never silently suffixed); combined with a move uses the target site.
16. Move table: collision on the target gives 409 with suggestion and leaves site and slug unchanged; free slug moves with the slug kept; move plus new slug checks the new slug on the target; a refused move changes no other field.
17. Delete frees the slug. Publish and unpublish keep it.
## D. Site POST and PATCH
18. Owner sets the three fields on PATCH (200, others unchanged); absent fields untouched; `GET /v1/site/` and POST responses carry them; defaults `""`, `"%title% | %site%"`, `""` when omitted on POST.
19. POST with the three fields: 201 and stored; creation validation table equals the PATCH tables.
20. Template table: valid templates give 200; `%site%`, `just text`, `%TITLE%`, empty give 400 `invalid_title_template`; 201 characters gives the validator text. Description 320 and `""` give 200, 321 gives the validator text; site_name 100 gives 200, 101 gives the validator text, `""` gives 200.
21. A refused PATCH leaves the other fields unchanged. `default_social_image_id` and `default_robots` still work.
22. Refusal table: admin and "editor" get 403 "Only the owner can do this." on PATCH and POST; API key gets 403 "This action needs a real session."; none gives 401; malformed JSON from an admin still gets the 403. A no-op edit is 200 with no event.
## E. Migration
23. Repository-contract harness (temp-dir SQLite; MySQL and Postgres opt-in through the existing env switches). `database.Migrate` backfill table: distinct titles; collisions in id order (`news`, `news-2`, `news-3`); suffix skipping existing slugs; per-site scope; titles `!!!`, `???`, `...` on one site give `article`, `article-2`, `article-3`; long titles stay within 100 and are distinct.
24. Idempotence: a second run changes nothing; an edited slug survives; a partially backfilled table completes without touching existing slugs.
25. New site columns default (`""`, `"%title% | %site%"`, `""`, no backfill of `site_name`); neutral article defaults; the unique index on (site_id, slug) exists and a duplicate insert is refused on every driver. `test/sqlite_schema_unchanged_test.go` is updated for the new columns and index.
26. Contract suites for the article and site repositories cover every new column, round-trip of `seo_dismissed`, null image ids and the per-site uniqueness.
## F. Media usage
27. Usage lookup: the item table for body, cover, social, avatar and site image, each with its `type`, `id`, `title` and `fields`; one item per article with merged `fields`; `published` present for articles (true or false) and absent for authors and sites; the article item keeps `id`, `title` and `published` with their previous values and the only new keys are `type` and `fields` (additive check); empty array when unused; unknown image gives the existing 404.
28. `used_in` and the `unused` filter count cover, social, site and avatar once per article, author and site; an article using the image in body and cover counts once.
29. Delete: never refused; references are nulled, `cover_image_alt` and article bodies are untouched, avatars and sites cleared as before, and a failed removal rolls back every reference.
30. Merge: articles' cover and social ids repointed; `articles_updated` counts each article once even if body and cover both changed; `updated_article_ids` lists them; `authors_updated` and `sites_updated` still correct. Purge does the same, a failed group leaves every reference on an existing image, and the preview treats cover and social as usage.
## G. Activity
31. Short-value table: slug (including a regenerated one), canonical_url, noindex, cover_image and social_image give one `article.edit` with `kind: "text"` and `before`/`after`, recorded after the change.
32. Free-text table (excerpt, seo_title, seo_description, social_title, cover_image_alt, and social_description) gives `kind: "body"` and `delta` in characters; the stored event and the `/v1/activity/` response contain none of the old or new text (substring check), including the same-length edit. Body plus SEO edit records both with no body text.
33. `seo_dismissed`-only and no-op edits record nothing. A taken slug records a failed event with the plain reason. No event for 404, malformed id, validation refusals, 401. A failing and a panicking audit repository change neither response nor data.
34. Site edits record `site.edit` with before and after for `site_name`, `title_template` and `default_description`. `GET /v1/activity/?category=articles` returns the events. `activity-log-producers.feature` carries the matching rule.
## H. Quality gates
35. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; the article and site domain packages import no GORM or HTTP; handlers import no persistence; `api/dependecyInjection.go` wires the widened `GetMediaUsageUseCase`; validation goes through validate tags and `validator.Validate.Struct`; no comments added.
36. Scoped mutation run through `task mutate` only, on the new and changed use cases (article create and update, site create and update, media usage, delete, merge, purge and list, and the slug helper). Example: `MUTATE_FILES=<those file names> MUTATE_RUN='TestSeoData' MUTATE_WORKERS=2 task mutate -- <use case package path>`. Report LIVED and NOT COVERED mutants and re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
37. Web: `web/src/data/__tests__/apiCatalogue.spec.ts` is updated for the new article and site request fields and runs under `task test:web`. Report NOT RUN if the runner is not installed.
38. Docs, checked by reading: `docs/api.md`, `docs/api/articles.md`, `docs/architecture.md`, `docs/release-notes.md` as listed in the feature.
## Manual
- MySQL and Postgres: run the contract suites with the opt-in env vars against real servers.
- Real boot on a copy of a pre-slice database: all articles get distinct slugs per site, nothing else changes, the second boot changes nothing.
- Docker with the `/data` volume: same boot check.
- Read the activity log screen and the CSV export: SEO events show field names and no free text.
- Not covered here: the SPA (slices 3 and 4) and the resolver, check endpoint and sitemap (slice 2).
+3 -2
View File
@@ -562,9 +562,10 @@ Feature: Sites backend, the owner manages sites and every article belongs to exa
Given the purge of a group fails midway
Then every site default_social_image_id refers to an image that still exists
Scenario: The usage lookup array shape is unchanged
Scenario: The usage lookup stays an array and later gains additive keys
When a verified account looks up the usage of an image
Then the response keeps the same array shape as before this slice
Then the response is a JSON array whose article items keep id, title and published
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
Rule: Every site write is recorded in the activity log
+1 -1
View File
@@ -67,7 +67,7 @@ Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test
## H. Media
38. Usage lookup counts a site's default social image, shape unchanged, image not "Unused".
38. Usage lookup counts a site's default social image, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), image not "Unused".
39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`.
40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image.
+102
View File
@@ -2,7 +2,11 @@ package article
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/slug"
"errors"
"net/url"
"slices"
"strconv"
"time"
"unicode/utf8"
)
@@ -11,13 +15,95 @@ var (
ErrInvalidTitle = errors.New("article's title length should be between 3 and 200 caracters")
ErrArticleNotFound = errors.New("article not found")
ErrArticleOffline = errors.New("article is offline")
ErrInvalidSlug = errors.New("invalid slug")
ErrSlugTaken = errors.New("another article on this site already uses that slug")
ErrInvalidCanonicalURL = errors.New("invalid canonical url")
)
type SlugTakenError struct {
Suggestion string
}
func (e *SlugTakenError) Error() string {
return ErrSlugTaken.Error()
}
func (e *SlugTakenError) Is(target error) bool {
return target == ErrSlugTaken
}
const (
MinTitleRunes = 3
MaxTitleRunes = 200
MaxCanonicalURLRunes = 2048
FallbackSlug = "article"
DeletedSlug = "#deleted-"
DismissTitleLong = "title_long"
DismissTitleEmpty = "title_empty"
DismissDescShort = "desc_short"
DismissDescLong = "desc_long"
DismissCoverAlt = "cover_alt"
DismissCoverNone = "cover_none"
DismissSlugLong = "slug_long"
DismissNoAuthor = "no_au"
DismissNoHeading = "no_h"
DismissBodyAlt = "body_alt"
DismissShort = "short"
)
var dismissCodes = []string{
DismissTitleLong, DismissTitleEmpty, DismissDescShort, DismissDescLong, DismissCoverAlt, DismissCoverNone,
DismissSlugLong, DismissNoAuthor, DismissNoHeading, DismissBodyAlt, DismissShort,
}
func DismissCodes() []string {
return slices.Clone(dismissCodes)
}
func NormalizeDismissed(codes []string) []string {
normalized := make([]string, 0, len(codes))
for _, code := range codes {
if !slices.Contains(normalized, code) {
normalized = append(normalized, code)
}
}
return normalized
}
func IsValidSlug(candidate string) bool {
return slug.IsValid(candidate)
}
func Tombstone(id uint32) string {
return DeletedSlug + strconv.FormatUint(uint64(id), 10)
}
func GenerateSlug(title string) string {
return slug.Generate(title, FallbackSlug)
}
func SlugWithSuffix(base string, attempt int) string {
return slug.WithSuffix(base, attempt)
}
func ValidateCanonicalURL(raw string) error {
if raw == "" {
return nil
}
if utf8.RuneCountInString(raw) > MaxCanonicalURLRunes {
return ErrInvalidCanonicalURL
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
return ErrInvalidCanonicalURL
}
return nil
}
func ValidateTitle(title string) error {
runeCount := utf8.RuneCountInString(title)
if runeCount < MinTitleRunes || runeCount > MaxTitleRunes {
@@ -33,6 +119,22 @@ type Article struct {
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
Authors []author.Byline `json:"authors"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (a Article) UsesImage(imageID uint32) bool {
return a.CoverImageID != nil && *a.CoverImageID == imageID || a.SocialImageID != nil && *a.SocialImageID == imageID
}
@@ -38,10 +38,36 @@ type Filters struct {
Sort Sort
}
type Nullable[T any] struct {
Set bool
Value *T
}
type Update struct {
Body *string
SiteID *uint32
AuthorIDs *[]uint32
Slug *string
Excerpt *string
SEOTitle *string
SEODescription *string
CoverImageID Nullable[uint32]
CoverImageAlt *string
SocialTitle *string
SocialDescription *string
SocialImageID Nullable[uint32]
CanonicalURL *string
Noindex *bool
SEODismissed *[]string
}
type ImageReferences struct {
Cover []uint32
Social []uint32
}
func (r ImageReferences) Empty() bool {
return len(r.Cover) == 0 && len(r.Social) == 0
}
type Counts struct {
@@ -68,4 +94,9 @@ type Repository interface {
UpdateIsOnline(id uint32, isOnline bool) (Article, error)
Delete(id uint32) error
FindByBodyContaining(filename string) ([]Article, error)
FindByImage(imageID uint32) ([]Article, error)
SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error)
ClearImage(imageID uint32) (ImageReferences, error)
RestoreImage(references ImageReferences, imageID uint32) error
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
}
+32 -7
View File
@@ -12,8 +12,33 @@ func TextChange(field string, before string, after string) Change {
}
func BodyChange(before string, after string) Change {
return SizeChange(FieldBody, before, after)
}
func SizeChange(field string, before string, after string) Change {
delta := utf8.RuneCountInString(after) - utf8.RuneCountInString(before)
return Change{Field: FieldBody, Kind: ChangeBody, Delta: &delta}
return Change{Field: field, Kind: ChangeBody, Delta: &delta}
}
func appendSizeChange(changes []Change, field string, before string, after string) []Change {
if before == after {
return changes
}
return append(changes, SizeChange(field, before, after))
}
func SEOChanges(before article.Article, after article.Article) []Change {
changes := []Change{}
changes = AppendTextChange(changes, FieldSlug, before.Slug, after.Slug)
changes = AppendTextChange(changes, FieldCanonicalURL, before.CanonicalURL, after.CanonicalURL)
changes = AppendTextChange(changes, FieldNoindex, strconv.FormatBool(before.Noindex), strconv.FormatBool(after.Noindex))
changes = appendSizeChange(changes, FieldExcerpt, before.Excerpt, after.Excerpt)
changes = appendSizeChange(changes, FieldSEOTitle, before.SEOTitle, after.SEOTitle)
changes = appendSizeChange(changes, FieldSEODescription, before.SEODescription, after.SEODescription)
changes = appendSizeChange(changes, FieldCoverImageAlt, before.CoverImageAlt, after.CoverImageAlt)
changes = appendSizeChange(changes, FieldSocialTitle, before.SocialTitle, after.SocialTitle)
changes = appendSizeChange(changes, FieldSocialDescription, before.SocialDescription, after.SocialDescription)
return changes
}
func ArticleChanges(before article.Article, after article.Article) []Change {
@@ -32,15 +57,15 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
func VariantChanges(before image.VariantSettings, after image.VariantSettings) []Change {
changes := []Change{}
changes = appendChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
changes = appendChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
changes = appendChange(changes, FieldFit, string(before.Fit), string(after.Fit))
changes = appendChange(changes, FieldFormat, string(before.Format), string(after.Format))
changes = appendChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
changes = AppendTextChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
changes = AppendTextChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
changes = AppendTextChange(changes, FieldFit, string(before.Fit), string(after.Fit))
changes = AppendTextChange(changes, FieldFormat, string(before.Format), string(after.Format))
changes = AppendTextChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
return changes
}
func appendChange(changes []Change, field string, before string, after string) []Change {
func AppendTextChange(changes []Change, field string, before string, after string) []Change {
if before == after {
return changes
}
+13
View File
@@ -170,6 +170,19 @@ const (
FieldAvatar = "avatar"
FieldUser = "user"
FieldAuthors = "authors"
FieldCanonicalURL = "canonical_url"
FieldNoindex = "noindex"
FieldCoverImage = "cover_image"
FieldArticleSocialImage = "social_image"
FieldExcerpt = "excerpt"
FieldSEOTitle = "seo_title"
FieldSEODescription = "seo_description"
FieldSocialTitle = "social_title"
FieldSocialDescription = "social_description"
FieldCoverImageAlt = "cover_image_alt"
FieldSiteName = "site_name"
FieldTitleTemplate = "title_template"
FieldDefaultDescription = "default_description"
maxNameLength = 255
maxActionLength = 64
maxFailureReasonSize = 255
+13 -49
View File
@@ -1,10 +1,9 @@
package author
import (
"RenewCMS/internal/domain/slug"
"errors"
"net/url"
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
@@ -41,31 +40,6 @@ const (
SocialCustom = "custom"
)
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var nonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
var diacriticFolder = strings.NewReplacer(
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
"æ", "ae",
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
"ď", "d", "đ", "d",
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
"ğ", "g",
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
"ł", "l", "ľ", "l",
"ñ", "n", "ń", "n", "ň", "n",
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
"œ", "oe",
"ř", "r",
"ś", "s", "š", "s", "ş", "s", "ș", "s",
"ß", "ss",
"ť", "t", "ț", "t", "ţ", "t",
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
"ý", "y", "ÿ", "y",
"ź", "z", "ż", "z", "ž", "z",
)
type SocialLink struct {
Type string `json:"type"`
URL string `json:"url"`
@@ -103,6 +77,14 @@ func (a Author) Byline() Byline {
return Byline{ID: a.ID, Name: a.Name, Slug: a.Slug, Bio: a.Bio, AvatarID: a.AvatarID, Website: a.Website, SocialLinks: links}
}
func BylineIDs(bylines []Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
}
return ids
}
type Nullable[T any] struct {
Set bool
Value *T
@@ -129,9 +111,8 @@ type Page struct {
Total int
}
func IsValidSlug(slug string) bool {
length := utf8.RuneCountInString(slug)
return length >= 1 && length <= MaxSlugRunes && slugPattern.MatchString(slug)
func IsValidSlug(candidate string) bool {
return slug.IsValid(candidate)
}
func IsValidName(name string) bool {
@@ -191,26 +172,9 @@ func ValidateLinks(links []SocialLink) error {
}
func GenerateSlug(name string) string {
folded := diacriticFolder.Replace(strings.ToLower(name))
slug := strings.Trim(nonSlugRun.ReplaceAllString(folded, "-"), "-")
slug = truncateSlug(slug, MaxSlugRunes)
if slug == "" {
return FallbackSlug
}
return slug
}
func truncateSlug(slug string, limit int) string {
if len(slug) <= limit {
return slug
}
return strings.TrimRight(slug[:limit], "-")
return slug.Generate(name, FallbackSlug)
}
func SlugWithSuffix(base string, attempt int) string {
if attempt <= 1 {
return base
}
suffix := "-" + strconv.Itoa(attempt)
return truncateSlug(base, MaxSlugRunes-len(suffix)) + suffix
return slug.WithSuffix(base, attempt)
}
@@ -10,6 +10,7 @@ type Repository interface {
DeleteMovingCredits(id uint32, reassignTo *uint32) error
DeleteUncrediting(id uint32) error
AvatarUsage() (map[uint32]int, error)
FindByAvatar(imageID uint32) ([]Author, error)
ClearAvatar(imageID uint32) ([]uint32, error)
AssignAvatar(authorIDs []uint32, imageID uint32) error
RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error)
+27 -2
View File
@@ -18,6 +18,8 @@ var (
ErrInvalidLocale = errors.New("invalid locale")
ErrUseSetDefault = errors.New("the default flag is changed through the default route")
ErrInvalidReassign = errors.New("invalid reassign destination")
ErrInvalidTitleTemplate = errors.New("the title template must contain %title%")
)
const (
@@ -30,8 +32,14 @@ const (
MinNameRunes = 1
MaxNameRunes = 100
MaxLocaleRunes = 35
MaxBaseURLRunes = 255
MinKeyRunes = 2
MaxKeyRunes = 32
TitlePlaceholder = "%title%"
DefaultTitleTemplate = "%title% | %site%"
)
var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
@@ -49,6 +57,9 @@ type Site struct {
Locale *string `json:"locale"`
IsDefault bool `json:"is_default"`
ArticleCount int `json:"article_count"`
SiteName string `json:"site_name"`
TitleTemplate string `json:"title_template"`
DefaultDescription string `json:"default_description"`
}
type Nullable[T any] struct {
@@ -64,6 +75,9 @@ type Patch struct {
Locale Nullable[string]
DefaultSocialImageID Nullable[uint32]
IsDefault *bool
SiteName *string
TitleTemplate *string
DefaultDescription *string
}
func IsValidKey(key string) bool {
@@ -76,11 +90,18 @@ func IsValidName(name string) bool {
return length >= MinNameRunes && length <= MaxNameRunes
}
func ValidateTitleTemplate(template string) error {
if !strings.Contains(template, TitlePlaceholder) {
return ErrInvalidTitleTemplate
}
return nil
}
func NormalizeLocale(locale *string) (*string, error) {
if locale == nil || *locale == "" {
return nil, nil
}
if !localePattern.MatchString(*locale) {
if utf8.RuneCountInString(*locale) > MaxLocaleRunes || !localePattern.MatchString(*locale) {
return nil, ErrInvalidLocale
}
return locale, nil
@@ -126,7 +147,11 @@ func NormalizeBaseURL(raw string) (string, error) {
if found.scheme == "http" && !isLoopback(found.hostname) {
return "", ErrInvalidSiteURL
}
return found.scheme + "://" + found.host, nil
normalized := found.scheme + "://" + found.host
if utf8.RuneCountInString(normalized) > MaxBaseURLRunes {
return "", ErrInvalidSiteURL
}
return normalized, nil
}
func SeedBaseURL(publicURL string) (string, error) {
+65
View File
@@ -0,0 +1,65 @@
package slug
import (
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
const MaxRunes = 100
var pattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var nonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
var diacriticFolder = strings.NewReplacer(
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
"æ", "ae",
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
"ď", "d", "đ", "d",
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
"ğ", "g",
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
"ł", "l", "ľ", "l",
"ñ", "n", "ń", "n", "ň", "n",
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
"œ", "oe",
"ř", "r",
"ś", "s", "š", "s", "ş", "s", "ș", "s",
"ß", "ss",
"ť", "t", "ț", "t", "ţ", "t",
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
"ý", "y", "ÿ", "y",
"ź", "z", "ż", "z", "ž", "z",
)
func IsValid(candidate string) bool {
length := utf8.RuneCountInString(candidate)
return length >= 1 && length <= MaxRunes && pattern.MatchString(candidate)
}
func Generate(text string, fallback string) string {
folded := diacriticFolder.Replace(strings.ToLower(text))
generated := strings.Trim(nonSlugRun.ReplaceAllString(folded, "-"), "-")
generated = truncate(generated, MaxRunes)
if generated == "" {
return fallback
}
return generated
}
func WithSuffix(base string, attempt int) string {
if attempt <= 1 {
return base
}
suffix := "-" + strconv.Itoa(attempt)
return truncate(base, MaxRunes-len(suffix)) + suffix
}
func truncate(candidate string, limit int) string {
if len(candidate) <= limit {
return candidate
}
return strings.TrimRight(candidate[:limit], "-")
}
@@ -0,0 +1,87 @@
package database
import (
"RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const (
articleSlugIndexName = "idx_articles_site_slug"
articleSlugIndexStatement = "CREATE UNIQUE INDEX " + articleSlugIndexName + " ON articles (site_id, slug)"
)
type slugOwner struct {
siteID uint32
slug string
}
type slugAssignment struct {
id uint32
slug string
}
func BackfillArticleSlugs(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var rows []entity.Article
if err := tx.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
return err
}
assignments := planSlugAssignments(rows)
for _, assignment := range assignments {
if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", assignment.id).UpdateColumn("slug", assignment.slug).Error; err != nil {
return err
}
}
return nil
})
}
func planSlugAssignments(rows []entity.Article) []slugAssignment {
assignments := []slugAssignment{}
claimed := map[slugOwner]bool{}
pending := []entity.Article{}
for _, row := range rows {
if row.DeletedAt.Valid {
if tombstone := article.Tombstone(row.ID); row.Slug != tombstone {
assignments = append(assignments, slugAssignment{row.ID, tombstone})
}
continue
}
owner := slugOwner{row.SiteID, row.Slug}
if row.Slug == "" || claimed[owner] {
pending = append(pending, row)
continue
}
claimed[owner] = true
}
for _, row := range pending {
base := row.Slug
attempt := 2
if base == "" {
base = snapshotSlug(row.Title)
attempt = 1
}
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt)
claimed[slugOwner{row.SiteID, slug}] = true
assignments = append(assignments, slugAssignment{row.ID, slug})
}
return assignments
}
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string {
for ; ; attempt++ {
candidate := snapshotSlugWithSuffix(base, attempt)
if !claimed[slugOwner{siteID, candidate}] {
return candidate
}
}
}
func ensureArticleSlugIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) {
return nil
}
return db.Exec(articleSlugIndexStatement).Error
}
@@ -0,0 +1,60 @@
package database
import (
"regexp"
"strconv"
"strings"
)
const (
snapshotMaxRunes = 100
snapshotFallbackSlug = "article"
)
var snapshotNonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
var snapshotFolder = strings.NewReplacer(
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
"æ", "ae",
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
"ď", "d", "đ", "d",
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
"ğ", "g",
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
"ł", "l", "ľ", "l",
"ñ", "n", "ń", "n", "ň", "n",
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
"œ", "oe",
"ř", "r",
"ś", "s", "š", "s", "ş", "s", "ș", "s",
"ß", "ss",
"ť", "t", "ț", "t", "ţ", "t",
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
"ý", "y", "ÿ", "y",
"ź", "z", "ż", "z", "ž", "z",
)
func snapshotSlug(text string) string {
folded := snapshotFolder.Replace(strings.ToLower(text))
generated := strings.Trim(snapshotNonSlugRun.ReplaceAllString(folded, "-"), "-")
generated = snapshotTruncate(generated, snapshotMaxRunes)
if generated == "" {
return snapshotFallbackSlug
}
return generated
}
func snapshotSlugWithSuffix(base string, attempt int) string {
if attempt <= 1 {
return base
}
suffix := "-" + strconv.Itoa(attempt)
return snapshotTruncate(base, snapshotMaxRunes-len(suffix)) + suffix
}
func snapshotTruncate(candidate string, limit int) string {
if len(candidate) <= limit {
return candidate
}
return strings.TrimRight(candidate[:limit], "-")
}
@@ -17,7 +17,16 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := BackfillUserRoles(db); err != nil {
return err
}
return SeedDefaultSite(db, publicURL)
if err := SeedDefaultSite(db, publicURL); err != nil {
return err
}
if err := BackfillSiteTitleTemplates(db); err != nil {
return err
}
if err := BackfillArticleSlugs(db); err != nil {
return err
}
return ensureArticleSlugIndex(db)
}
const (
@@ -36,6 +36,7 @@ func ensureDefaultSite(tx *gorm.DB, publicURL string) (entity.Site, error) {
BaseURL: baseURL,
DefaultRobots: domainSite.RobotsIndex,
IsDefault: true,
TitleTemplate: domainSite.DefaultTitleTemplate,
}
if err := tx.Create(&seeded).Error; err != nil {
return entity.Site{}, err
@@ -50,3 +51,7 @@ func firstDefaultSite(tx *gorm.DB) (entity.Site, error) {
}
return found, nil
}
func BackfillSiteTitleTemplates(db *gorm.DB) error {
return db.Model(&entity.Site{}).Where("title_template IS NULL OR title_template = ''").UpdateColumn("title_template", domainSite.DefaultTitleTemplate).Error
}
@@ -3,8 +3,33 @@ package mappers
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
const emptyDismissed = "[]"
func decodeDismissed(raw string) []string {
codes := []string{}
if raw == "" {
return codes
}
if err := json.Unmarshal([]byte(raw), &codes); err != nil || codes == nil {
return []string{}
}
return codes
}
func encodeDismissed(codes []string) string {
if len(codes) == 0 {
return emptyDismissed
}
encoded, err := json.Marshal(codes)
if err != nil {
return emptyDismissed
}
return string(encoded)
}
func ArticleToDomain(m models.Article) article.Article {
return article.Article{
ID: m.ID,
@@ -12,6 +37,18 @@ func ArticleToDomain(m models.Article) article.Article {
Body: m.Body,
IsOnline: m.IsOnline,
SiteID: m.SiteID,
Slug: m.Slug,
Excerpt: m.Excerpt,
SEOTitle: m.SEOTitle,
SEODescription: m.SEODescription,
CoverImageID: m.CoverImageID,
CoverImageAlt: m.CoverImageAlt,
SocialTitle: m.SocialTitle,
SocialDescription: m.SocialDescription,
SocialImageID: m.SocialImageID,
CanonicalURL: m.CanonicalURL,
Noindex: m.Noindex,
SEODismissed: decodeDismissed(m.SEODismissed),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
@@ -23,6 +60,18 @@ func ArticleToModel(a article.Article) models.Article {
Body: a.Body,
IsOnline: a.IsOnline,
SiteID: a.SiteID,
Slug: a.Slug,
Excerpt: a.Excerpt,
SEOTitle: a.SEOTitle,
SEODescription: a.SEODescription,
CoverImageID: a.CoverImageID,
CoverImageAlt: a.CoverImageAlt,
SocialTitle: a.SocialTitle,
SocialDescription: a.SocialDescription,
SocialImageID: a.SocialImageID,
CanonicalURL: a.CanonicalURL,
Noindex: a.Noindex,
SEODismissed: encodeDismissed(a.SEODismissed),
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
@@ -6,6 +6,10 @@ import (
)
func SiteToDomain(m models.Site, articleCount int) site.Site {
titleTemplate := m.TitleTemplate
if titleTemplate == "" {
titleTemplate = site.DefaultTitleTemplate
}
return site.Site{
ID: m.ID,
Name: m.Name,
@@ -16,6 +20,9 @@ func SiteToDomain(m models.Site, articleCount int) site.Site {
Locale: m.Locale,
IsDefault: m.IsDefault,
ArticleCount: articleCount,
SiteName: m.SiteName,
TitleTemplate: titleTemplate,
DefaultDescription: m.DefaultDescription,
}
}
@@ -29,5 +36,8 @@ func SiteToModel(s site.Site) models.Site {
DefaultRobots: s.DefaultRobots,
Locale: s.Locale,
IsDefault: s.IsDefault,
SiteName: s.SiteName,
TitleTemplate: s.TitleTemplate,
DefaultDescription: s.DefaultDescription,
}
}
@@ -13,6 +13,18 @@ type Article struct {
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32 `gorm:"index"`
Slug string `gorm:"size:100;not null;default:''"`
Excerpt string `gorm:"size:300;not null;default:''"`
SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"`
SEODescription string `gorm:"column:seo_description;size:320;not null;default:''"`
CoverImageID *uint32 `gorm:"index"`
CoverImageAlt string `gorm:"size:200;not null;default:''"`
SocialTitle string `gorm:"size:120;not null;default:''"`
SocialDescription string `gorm:"size:320;not null;default:''"`
SocialImageID *uint32 `gorm:"index"`
CanonicalURL string `gorm:"column:canonical_url;size:2048;not null;default:''"`
Noindex bool `gorm:"not null;default:false"`
SEODismissed string `gorm:"column:seo_dismissed;type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -11,6 +11,9 @@ type Site struct {
DefaultRobots string `gorm:"size:16;not null"`
Locale *string `gorm:"size:35"`
IsDefault bool `gorm:"not null"`
SiteName string `gorm:"size:100;not null;default:''"`
TitleTemplate string `gorm:"size:200"`
DefaultDescription string `gorm:"size:320;not null;default:''"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -5,6 +5,7 @@ import (
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"strings"
"unicode"
@@ -260,35 +261,59 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := entity.Article{
Title: article.Title,
Body: article.Body,
IsOnline: article.IsOnline,
SiteID: article.SiteID,
}
created := mappers.ArticleToModel(article)
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 := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
return err
}
if err := tx.Create(&created).Error; err != nil {
return err
}
return replaceCredits(tx, created.ID, bylineIDs(article.Authors))
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
})
if err != nil {
return domain.Article{}, err
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func bylineIDs(bylines []authordomain.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
var total int64
err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
return total > 0, err
}
return ids
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
if slug == "" {
return nil
}
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
if err != nil {
return err
}
if taken {
return domain.ErrSlugTaken
}
return nil
}
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
if errors.Is(cause, domain.ErrSlugTaken) {
return cause
}
if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
return err
}
return cause
}
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
return articleSlugInUse(a.db, siteID, slug, exceptID)
}
func (a *ArticleRepository) GetAll() []domain.Article {
@@ -333,11 +358,16 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if update.Body != nil {
localArticle.Body = *update.Body
if update.SiteID == nil && update.Slug != nil {
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
return err
}
}
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
return err
}
if update.SiteID != nil {
localArticle.SiteID = *update.SiteID
}
if err := tx.Save(&localArticle).Error; err != nil {
return err
@@ -348,11 +378,44 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
return nil
})
if err != nil {
return domain.Article{}, err
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func applyUpdate(target *entity.Article, update domain.Update) {
setString(&target.Body, update.Body)
setString(&target.Slug, update.Slug)
setString(&target.Excerpt, update.Excerpt)
setString(&target.SEOTitle, update.SEOTitle)
setString(&target.SEODescription, update.SEODescription)
setString(&target.CoverImageAlt, update.CoverImageAlt)
setString(&target.SocialTitle, update.SocialTitle)
setString(&target.SocialDescription, update.SocialDescription)
setString(&target.CanonicalURL, update.CanonicalURL)
if update.SiteID != nil {
target.SiteID = *update.SiteID
}
if update.Noindex != nil {
target.Noindex = *update.Noindex
}
if update.CoverImageID.Set {
target.CoverImageID = update.CoverImageID.Value
}
if update.SocialImageID.Set {
target.SocialImageID = update.SocialImageID.Value
}
if update.SEODismissed != nil {
target.SEODismissed = mappers.ArticleToModel(domain.Article{SEODismissed: *update.SEODismissed}).SEODismissed
}
}
func setString(target *string, value *string) {
if value != nil {
*target = *value
}
}
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
@@ -371,6 +434,14 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
func (a *ArticleRepository) Delete(id uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
var found entity.Article
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 {
return err
}
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err
}
@@ -388,4 +459,73 @@ func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Arti
return toDomainArticles(entityArticles), nil
}
func (a *ArticleRepository) FindByImage(imageID uint32) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("cover_image_id = ? OR social_image_id = ?", imageID, imageID).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
return toDomainArticles(entityArticles), nil
}
func (a *ArticleRepository) ClearImage(imageID uint32) (domain.ImageReferences, error) {
references := domain.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := clearColumn(tx, "cover_image_id", imageID, &references.Cover); err != nil {
return err
}
return clearColumn(tx, "social_image_id", imageID, &references.Social)
})
if err != nil {
return domain.ImageReferences{}, err
}
return references, nil
}
func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32) error {
if err := tx.Model(&entity.Article{}).Where(column+" = ?", imageID).Order("id ASC").Pluck("id", cleared).Error; err != nil {
return err
}
if len(*cleared) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
}
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if err := assignColumn(tx, "cover_image_id", references.Cover, imageID); err != nil {
return err
}
return assignColumn(tx, "social_image_id", references.Social, imageID)
})
}
func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint32) error {
if len(articleIDs) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
}
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
changed := []uint32{}
if len(fromImageIDs) == 0 {
return changed, nil
}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
return err
}
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil {
return err
}
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error
})
if err != nil {
return nil, err
}
return changed, nil
}
var _ domain.Repository = &ArticleRepository{}
@@ -319,6 +319,14 @@ func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) {
return usage, nil
}
func (a *AuthorRepository) FindByAvatar(imageID uint32) ([]domain.Author, error) {
var found []entity.Author
if err := a.db.Where("avatar_id = ?", imageID).Order("id ASC").Find(&found).Error; err != nil {
return nil, err
}
return a.toDomain(a.db, found)
}
func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) {
ids := []uint32{}
err := a.db.Transaction(func(tx *gorm.DB) error {
@@ -106,11 +106,18 @@ func (s *SiteRepository) Create(site domain.Site) (domain.Site, error) {
return tx.Create(&model).Error
})
if err != nil {
return domain.Site{}, err
return domain.Site{}, s.explainKeyFailure(model.Key, err)
}
return mappers.SiteToDomain(model, 0), nil
}
func (s *SiteRepository) explainKeyFailure(key string, cause error) error {
if _, err := s.GetByKey(key); err == nil {
return domain.ErrSiteKeyTaken
}
return cause
}
func lockAllSites(tx *gorm.DB) error {
var locked []entity.Site
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&locked).Error
@@ -135,6 +142,9 @@ func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
"default_social_image_id": site.DefaultSocialImageID,
"default_robots": site.DefaultRobots,
"locale": site.Locale,
"site_name": site.SiteName,
"title_template": site.TitleTemplate,
"default_description": site.DefaultDescription,
}
if _, err := s.Get(site.ID); err != nil {
return domain.Site{}, err
@@ -66,12 +66,13 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err
}
created, err := g.articleRepository.Create(article.Article{
created, err := g.createWithFreeSlug(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
Authors: creditedAuthors,
SEODismissed: []string{},
})
if err != nil {
recordFailure(ctx, g.recorder, event, err)
@@ -83,6 +84,22 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return renderArticle(g.bodyRenderer, created, createArticle.Format)
}
func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (article.Article, error) {
base := article.GenerateSlug(draft.Title)
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
slug, err := freeSlug(g.articleRepository, draft.SiteID, base, 0, firstSlugAttempt)
if err != nil {
return article.Article{}, err
}
draft.Slug = slug
created, err := g.articleRepository.Create(draft)
if !isSlugTakenError(err) {
return created, err
}
}
return article.Article{}, article.ErrSlugTaken
}
func articleTarget(a article.Article) audit.Target {
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
}
@@ -21,6 +21,9 @@ type CreateSiteCommand struct {
Locale *string
DefaultSocialImageID *uint32
IsDefault bool
SiteName string
TitleTemplate *string
DefaultDescription string
}
func NewCreateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *CreateSiteUseCase {
@@ -52,6 +55,10 @@ func (u *CreateSiteUseCase) create(command CreateSiteCommand) (site.Site, error)
if err := ensureImageExists(u.imageRepository, command.DefaultSocialImageID); err != nil {
return site.Site{}, err
}
titleTemplate, err := resolveTitleTemplate(command.TitleTemplate)
if err != nil {
return site.Site{}, err
}
taken, err := isKeyTaken(u.siteRepository, command.Key)
if err != nil {
return site.Site{}, err
@@ -71,5 +78,18 @@ func (u *CreateSiteUseCase) create(command CreateSiteCommand) (site.Site, error)
DefaultRobots: robots,
Locale: locale,
IsDefault: command.IsDefault,
SiteName: command.SiteName,
TitleTemplate: titleTemplate,
DefaultDescription: command.DefaultDescription,
})
}
func resolveTitleTemplate(requested *string) (string, error) {
if requested == nil {
return site.DefaultTitleTemplate, nil
}
if err := site.ValidateTitleTemplate(*requested); err != nil {
return "", err
}
return *requested, nil
}
@@ -1,11 +1,13 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
"log"
)
type DeleteMediaUseCase struct {
@@ -14,10 +16,17 @@ type DeleteMediaUseCase struct {
recorder audit.Recorder
sites site.Repository
authors author.Repository
articles article.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository, articles article.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors, articles: articles}
}
func logCompensation(step string, err error) {
if err != nil {
log.Println("media compensation failed to", step+":", err)
}
}
func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
@@ -27,12 +36,19 @@ func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
}
clearedAuthors, err := u.authors.ClearAvatar(img.ID)
if err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
logCompensation("restore site images", u.sites.AssignImage(clearedSites, img.ID))
return err
}
clearedArticles, err := u.articles.ClearImage(img.ID)
if err != nil {
logCompensation("restore site images", u.sites.AssignImage(clearedSites, img.ID))
logCompensation("restore author avatars", u.authors.AssignAvatar(clearedAuthors, img.ID))
return err
}
if err := u.remover.remove(img); err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
_ = u.authors.AssignAvatar(clearedAuthors, img.ID)
logCompensation("restore site images", u.sites.AssignImage(clearedSites, img.ID))
logCompensation("restore author avatars", u.authors.AssignAvatar(clearedAuthors, img.ID))
logCompensation("restore article images", u.articles.RestoreImage(clearedArticles, img.ID))
return err
}
return nil
@@ -2,22 +2,136 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"slices"
"strings"
)
const (
UsageTypeArticle = "article"
UsageTypeAuthor = "author"
UsageTypeSite = "site"
UsageFieldBody = "body"
UsageFieldCover = "cover"
UsageFieldSocial = "social"
UsageFieldAvatar = "avatar"
UsageFieldDefaultSocialImage = "default_social_image"
)
type MediaUsage struct {
Type string
ID uint32
Title string
Published *bool
Fields []string
}
type GetMediaUsageUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
sites site.Repository
authors author.Repository
}
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository) *GetMediaUsageUseCase {
return &GetMediaUsageUseCase{imageRepository, articleRepository}
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *GetMediaUsageUseCase {
return &GetMediaUsageUseCase{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}
}
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]article.Article, error) {
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]MediaUsage, error) {
img, err := u.imageRepository.Get(id)
if err != nil {
return nil, err
}
return u.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
usages, err := u.articleUsages(img)
if err != nil {
return nil, err
}
authorUsages, err := u.authorUsages(img)
if err != nil {
return nil, err
}
siteUsages, err := u.siteUsages(img)
if err != nil {
return nil, err
}
return slices.Concat(usages, authorUsages, siteUsages), nil
}
func (u *GetMediaUsageUseCase) articleUsages(img image.Image) ([]MediaUsage, error) {
storedName := image.StoredName(img.Path)
inBody, err := u.articleRepository.FindByBodyContaining(storedName)
if err != nil {
return nil, err
}
referencing, err := u.articleRepository.FindByImage(img.ID)
if err != nil {
return nil, err
}
byID := map[uint32]*MediaUsage{}
for _, found := range inBody {
if !strings.Contains(found.Body, storedName) {
continue
}
entry := usageFor(byID, found)
entry.Fields = append(entry.Fields, UsageFieldBody)
}
for _, found := range referencing {
entry := usageFor(byID, found)
if found.CoverImageID != nil && *found.CoverImageID == img.ID {
entry.Fields = append(entry.Fields, UsageFieldCover)
}
if found.SocialImageID != nil && *found.SocialImageID == img.ID {
entry.Fields = append(entry.Fields, UsageFieldSocial)
}
}
usages := make([]MediaUsage, 0, len(byID))
for _, entry := range byID {
usages = append(usages, *entry)
}
slices.SortFunc(usages, func(a, b MediaUsage) int { return int(a.ID) - int(b.ID) })
return usages, nil
}
func usageFor(byID map[uint32]*MediaUsage, found article.Article) *MediaUsage {
if entry, ok := byID[found.ID]; ok {
return entry
}
published := found.IsOnline
entry := &MediaUsage{Type: UsageTypeArticle, ID: found.ID, Title: found.Title, Published: &published, Fields: []string{}}
byID[found.ID] = entry
return entry
}
func (u *GetMediaUsageUseCase) authorUsages(img image.Image) ([]MediaUsage, error) {
found, err := u.authors.FindByAvatar(img.ID)
if err != nil {
return nil, err
}
usages := make([]MediaUsage, 0, len(found))
for _, a := range found {
usages = append(usages, MediaUsage{Type: UsageTypeAuthor, ID: a.ID, Title: a.Name, Fields: []string{UsageFieldAvatar}})
}
return usages, nil
}
func (u *GetMediaUsageUseCase) siteUsages(img image.Image) ([]MediaUsage, error) {
all, err := u.sites.List()
if err != nil {
return nil, err
}
usages := []MediaUsage{}
for _, s := range all {
if s.DefaultSocialImageID != nil && *s.DefaultSocialImageID == img.ID {
usages = append(usages, MediaUsage{Type: UsageTypeSite, ID: s.ID, Title: s.Name, Fields: []string{UsageFieldDefaultSocialImage}})
}
}
slices.SortFunc(usages, func(a, b MediaUsage) int { return int(a.ID) - int(b.ID) })
return usages, nil
}
func articleMentionsImage(a article.Article, img image.Image) bool {
return strings.Contains(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
}
+20 -4
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"slices"
"strings"
)
@@ -106,7 +107,7 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
}
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
articles, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
articleCount, err := l.articleUsageCount(img)
if err != nil {
return MediaItem{}, err
}
@@ -137,7 +138,23 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
variantSize += row.Size
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles) + siteUsage[img.ID], Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: articleCount + siteUsage[img.ID], Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
}
func (l *ListMediaUseCase) articleUsageCount(img image.Image) (int, error) {
inBody, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
if err != nil {
return 0, err
}
referencing, err := l.articleRepository.FindByImage(img.ID)
if err != nil {
return 0, err
}
distinct := map[uint32]bool{}
for _, found := range slices.Concat(inBody, referencing) {
distinct[found.ID] = true
}
return len(distinct), nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
@@ -150,10 +167,9 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
return nil, err
}
return func(img image.Image) int {
storedName := image.StoredName(img.Path)
used := siteUsage[img.ID]
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
if articleMentionsImage(a, img) {
used++
}
}
+17 -5
View File
@@ -10,6 +10,7 @@ import (
"context"
"errors"
"slices"
"strconv"
"strings"
)
@@ -100,6 +101,11 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
if err := u.applyRewrites(rewrites); err != nil {
return MergeMediaResult{}, err
}
repointedArticles, err := u.articleRepository.RepointImage(copyIdsOf(copies), kept.ID)
if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err
}
sitesUpdated, err := u.repointSites(copies, kept.ID)
if err != nil {
u.restoreBodies(rewrites)
@@ -118,15 +124,17 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
updatedIds := updatedArticleIds(rewrites, repointedArticles)
return MergeMediaResult{ArticlesUpdated: len(updatedIds), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: updatedIds}, nil
}
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
ids := make([]uint32, 0, len(rewrites))
func updatedArticleIds(rewrites []bodyRewrite, repointed []uint32) []uint32 {
ids := slices.Clone(repointed)
for _, plan := range rewrites {
ids = append(ids, plan.articleId)
}
return ids
slices.Sort(ids)
return slices.Compact(ids)
}
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
@@ -172,6 +180,9 @@ func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image)
return nil, err
}
for _, a := range articles {
if !strings.Contains(a.Body, copyName) {
continue
}
plan, ok := rewritten[a.ID]
if !ok {
plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body}
@@ -201,6 +212,7 @@ func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error {
func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) {
for _, plan := range applied {
_, _ = u.articleRepository.UpdateBody(plan.articleId, plan.original)
_, err := u.articleRepository.UpdateBody(plan.articleId, plan.original)
logCompensation("restore article body "+strconv.FormatUint(uint64(plan.articleId), 10), err)
}
}
+132 -13
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
"slices"
@@ -17,17 +18,30 @@ type UpdateArticleUseCase struct {
recorder audit.Recorder
sites site.Repository
authors author.Repository
images image.Repository
}
type UpdateArticleCommand struct {
Body *string
SiteID *uint32
AuthorIDs *[]uint32
Slug *string
Excerpt *string
SEOTitle *string
SEODescription *string
CoverImageID article.Nullable[uint32]
CoverImageAlt *string
SocialTitle *string
SocialDescription *string
SocialImageID article.Nullable[uint32]
CanonicalURL *string
Noindex *bool
SEODismissed *[]string
Format article.BodyFormat
}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
func 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 (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
@@ -46,7 +60,20 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID, AuthorIDs: command.AuthorIDs})
if err := g.validateSEO(command); err != nil {
return article.Article{}, err
}
update := command.toUpdate()
slug, err := g.resolveSlug(before, command)
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
}
update.Slug = slug
updated, err := g.articleRepository.Update(id, update)
if isSlugTakenError(err) {
err = g.raceConflict(before, command, slug)
}
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
@@ -55,6 +82,93 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
return renderArticle(g.bodyRenderer, updated, command.Format)
}
func (c UpdateArticleCommand) toUpdate() article.Update {
update := article.Update{
Body: c.Body,
SiteID: c.SiteID,
AuthorIDs: c.AuthorIDs,
Excerpt: c.Excerpt,
SEOTitle: c.SEOTitle,
SEODescription: c.SEODescription,
CoverImageID: c.CoverImageID,
CoverImageAlt: c.CoverImageAlt,
SocialTitle: c.SocialTitle,
SocialDescription: c.SocialDescription,
SocialImageID: c.SocialImageID,
CanonicalURL: c.CanonicalURL,
Noindex: c.Noindex,
}
if c.SEODismissed != nil {
normalized := article.NormalizeDismissed(*c.SEODismissed)
update.SEODismissed = &normalized
}
return update
}
func (g *UpdateArticleUseCase) validateSEO(command UpdateArticleCommand) error {
if command.Slug != nil && *command.Slug != "" && !article.IsValidSlug(*command.Slug) {
return article.ErrInvalidSlug
}
if command.CanonicalURL != nil {
if err := article.ValidateCanonicalURL(*command.CanonicalURL); err != nil {
return err
}
}
if command.CoverImageID.Set {
if err := ensureImageExists(g.images, command.CoverImageID.Value); err != nil {
return err
}
}
if command.SocialImageID.Set {
return ensureImageExists(g.images, command.SocialImageID.Value)
}
return nil
}
func targetSiteID(before article.Article, command UpdateArticleCommand) uint32 {
if command.SiteID != nil {
return *command.SiteID
}
return before.SiteID
}
func requestedSlug(before article.Article, command UpdateArticleCommand) string {
if command.Slug == nil {
return before.Slug
}
if *command.Slug == "" {
return article.GenerateSlug(before.Title)
}
return *command.Slug
}
func (g *UpdateArticleUseCase) resolveSlug(before article.Article, command UpdateArticleCommand) (*string, error) {
target := targetSiteID(before, command)
slug := requestedSlug(before, command)
if slug == "" || (slug == before.Slug && target == before.SiteID) {
return nil, nil
}
taken, err := g.articleRepository.SlugTaken(target, slug, before.ID)
if err != nil {
return nil, err
}
if taken {
return nil, slugConflict(g.articleRepository, target, slug, before.ID)
}
if slug == before.Slug {
return nil, nil
}
return &slug, nil
}
func (g *UpdateArticleUseCase) raceConflict(before article.Article, command UpdateArticleCommand, chosen *string) error {
slug := requestedSlug(before, command)
if chosen != nil {
slug = *chosen
}
return slugConflict(g.articleRepository, targetSiteID(before, command), slug, before.ID)
}
func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
if command.SiteID == nil {
return nil, nil
@@ -79,22 +193,14 @@ func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
}
func authorsChange(before article.Article, after article.Article) (audit.Change, bool) {
beforeIDs := bylineIDs(before.Authors)
afterIDs := bylineIDs(after.Authors)
beforeIDs := author.BylineIDs(before.Authors)
afterIDs := author.BylineIDs(after.Authors)
if slices.Equal(beforeIDs, afterIDs) {
return audit.Change{}, false
}
return audit.TextChange(audit.FieldAuthors, bylineNames(before.Authors), bylineNames(after.Authors)), true
}
func bylineIDs(bylines []author.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
}
return ids
}
func bylineNames(bylines []author.Byline) string {
names := make([]string, 0, len(bylines))
for _, byline := range bylines {
@@ -105,6 +211,8 @@ func bylineNames(bylines []author.Byline) string {
func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) {
changes := audit.ArticleChanges(before, after)
changes = append(changes, audit.SEOChanges(before, after)...)
changes = append(changes, g.imageChanges(before, after)...)
if destination != nil && before.SiteID != after.SiteID {
changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name))
}
@@ -120,6 +228,17 @@ func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Ar
g.recorder.Record(ctx, event)
}
func (g *UpdateArticleUseCase) imageChanges(before article.Article, after article.Article) []audit.Change {
changes := []audit.Change{}
if !sameImage(before.CoverImageID, after.CoverImageID) {
changes = append(changes, audit.TextChange(audit.FieldCoverImage, imageLabel(g.images, before.CoverImageID), imageLabel(g.images, after.CoverImageID)))
}
if !sameImage(before.SocialImageID, after.SocialImageID) {
changes = append(changes, audit.TextChange(audit.FieldArticleSocialImage, imageLabel(g.images, before.SocialImageID), imageLabel(g.images, after.SocialImageID)))
}
return changes
}
func (g *UpdateArticleUseCase) siteName(id uint32) string {
found, err := g.sites.Get(id)
if err != nil {
+19 -11
View File
@@ -80,28 +80,36 @@ func (u *UpdateSiteUseCase) apply(before site.Site, patch site.Patch) (site.Site
}
after.DefaultSocialImageID = patch.DefaultSocialImageID.Value
}
if patch.SiteName != nil {
after.SiteName = *patch.SiteName
}
if patch.TitleTemplate != nil {
if err := site.ValidateTitleTemplate(*patch.TitleTemplate); err != nil {
return site.Site{}, err
}
after.TitleTemplate = *patch.TitleTemplate
}
if patch.DefaultDescription != nil {
after.DefaultDescription = *patch.DefaultDescription
}
return after, nil
}
func (u *UpdateSiteUseCase) changes(before site.Site, after site.Site) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldBaseURL, before.BaseURL, after.BaseURL)
changes = appendTextChange(changes, audit.FieldDefaultRobots, before.DefaultRobots, after.DefaultRobots)
changes = appendTextChange(changes, audit.FieldLocale, localeLabel(before.Locale), localeLabel(after.Locale))
changes = audit.AppendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = audit.AppendTextChange(changes, audit.FieldBaseURL, before.BaseURL, after.BaseURL)
changes = audit.AppendTextChange(changes, audit.FieldDefaultRobots, before.DefaultRobots, after.DefaultRobots)
changes = audit.AppendTextChange(changes, audit.FieldLocale, localeLabel(before.Locale), localeLabel(after.Locale))
if !sameImage(before.DefaultSocialImageID, after.DefaultSocialImageID) {
changes = append(changes, audit.TextChange(audit.FieldSocialImage, imageLabel(u.imageRepository, before.DefaultSocialImageID), imageLabel(u.imageRepository, after.DefaultSocialImageID)))
}
changes = audit.AppendTextChange(changes, audit.FieldSiteName, before.SiteName, after.SiteName)
changes = audit.AppendTextChange(changes, audit.FieldTitleTemplate, before.TitleTemplate, after.TitleTemplate)
changes = audit.AppendTextChange(changes, audit.FieldDefaultDescription, before.DefaultDescription, after.DefaultDescription)
return changes
}
func appendTextChange(changes []audit.Change, field string, before string, after string) []audit.Change {
if before == after {
return changes
}
return append(changes, audit.TextChange(field, before, after))
}
func sameImage(a *uint32, b *uint32) bool {
if a == nil || b == nil {
return a == b
@@ -0,0 +1,39 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"errors"
)
const (
firstSlugAttempt = 1
firstSuggestedAttempt = 2
maxCreateSlugAttempts = 5
maxFreeSlugAttempts = 100
)
func freeSlug(articles article.Repository, siteID uint32, base string, exceptID uint32, firstAttempt int) (string, error) {
for attempt := firstAttempt; attempt <= maxFreeSlugAttempts; attempt++ {
candidate := article.SlugWithSuffix(base, attempt)
taken, err := articles.SlugTaken(siteID, candidate, exceptID)
if err != nil {
return "", err
}
if !taken {
return candidate, nil
}
}
return "", article.ErrSlugTaken
}
func slugConflict(articles article.Repository, siteID uint32, slug string, exceptID uint32) error {
suggestion, err := freeSlug(articles, siteID, slug, exceptID, firstSuggestedAttempt)
if err != nil {
return err
}
return &article.SlugTakenError{Suggestion: suggestion}
}
func isSlugTakenError(err error) bool {
return errors.Is(err, article.ErrSlugTaken)
}
@@ -24,6 +24,7 @@ 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."},
{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."},
@@ -63,6 +64,7 @@ var failureReasons = []failureReason{
{site.ErrSiteHasArticles, "The site still has articles."},
{site.ErrInvalidSiteURL, "The site address is not valid."},
{site.ErrInvalidLocale, "The locale is not valid."},
{site.ErrInvalidTitleTemplate, "The title template must contain %title%."},
{site.ErrUseSetDefault, "Use the default site action to change the default."},
{site.ErrInvalidReassign, "The destination site is not valid."},
{author.ErrSlugTaken, "That slug is already used by another author."},
@@ -100,11 +100,11 @@ func sameID(a *uint32, b *uint32) bool {
func authorChanges(users user.Repository, images image.Repository, before author.Author, after author.Author) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
changes = appendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
changes = appendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
changes = appendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
changes = audit.AppendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = audit.AppendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
changes = audit.AppendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
changes = audit.AppendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
changes = audit.AppendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
if !sameID(before.AvatarID, after.AvatarID) {
changes = append(changes, audit.TextChange(audit.FieldAvatar, imageLabel(images, before.AvatarID), imageLabel(images, after.AvatarID)))
}
@@ -86,10 +86,9 @@ func groupByHash(images []image.Image) [][]image.Image {
}
func usageOf(img image.Image, articles []article.Article) int {
storedName := image.StoredName(img.Path)
used := 0
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
if articleMentionsImage(a, img) {
used++
}
}
@@ -135,16 +134,16 @@ func isBetterToKeep(candidate, current image.Image, usage map[uint32]int) bool {
}
func articlesUsingCopies(group purgeGroup, members []image.Image, articles []article.Article) []uint32 {
mergedNames := []string{}
merged := []image.Image{}
for _, member := range members {
if member.ID != group.kept.ID {
mergedNames = append(mergedNames, image.StoredName(member.Path))
merged = append(merged, member)
}
}
ids := []uint32{}
for _, a := range articles {
for _, name := range mergedNames {
if strings.Contains(a.Body, name) {
for _, copied := range merged {
if articleMentionsImage(a, copied) {
ids = append(ids, a.ID)
break
}
+2 -2
View File
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
t.Fatal(err)
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil {
t.Fatal(err)
+3 -3
View File
@@ -108,7 +108,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
@@ -129,11 +129,11 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), env.articles),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites(), plainAuthors()),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors()),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
+1 -1
View File
@@ -47,7 +47,7 @@ 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()),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -26,7 +26,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -27,7 +27,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+135
View File
@@ -23,6 +23,8 @@ type mockArticleRepo struct {
createCalled bool
credits map[uint32][]uint32
authors *mockAuthorRepo
reportSlugRace bool
createAlwaysRaces bool
}
func newMockArticleRepo() *mockArticleRepo {
@@ -193,11 +195,107 @@ func (m *mockArticleRepo) GetAll() []article.Article {
return result
}
func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool {
if slug == "" {
return false
}
for id, other := range m.articles {
if id != exceptID && other.SiteID == siteID && other.Slug == slug {
return true
}
}
return false
}
func (m *mockArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
if err := m.fail.trigger("SlugTaken"); err != nil {
return false, err
}
return m.slugUsedOn(siteID, slug, exceptID), nil
}
func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) {
if err := m.fail.trigger("FindByImage"); err != nil {
return nil, err
}
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
if a := m.articles[id]; a.UsesImage(imageID) {
result = append(result, a)
}
}
return result, nil
}
func (m *mockArticleRepo) ClearImage(imageID uint32) (article.ImageReferences, error) {
if err := m.fail.trigger("ClearImage"); err != nil {
return article.ImageReferences{}, err
}
references := article.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
for _, id := range m.sortedIds() {
a := m.articles[id]
if a.CoverImageID != nil && *a.CoverImageID == imageID {
a.CoverImageID = nil
references.Cover = append(references.Cover, id)
}
if a.SocialImageID != nil && *a.SocialImageID == imageID {
a.SocialImageID = nil
references.Social = append(references.Social, id)
}
m.articles[id] = a
}
return references, nil
}
func (m *mockArticleRepo) RestoreImage(references article.ImageReferences, imageID uint32) error {
for _, id := range references.Cover {
a := m.articles[id]
a.CoverImageID = idPointer(imageID)
m.articles[id] = a
}
for _, id := range references.Social {
a := m.articles[id]
a.SocialImageID = idPointer(imageID)
m.articles[id] = a
}
return nil
}
func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
if err := m.fail.trigger("RepointImage"); err != nil {
return nil, err
}
changed := []uint32{}
for _, id := range m.sortedIds() {
a := m.articles[id]
touched := false
if a.CoverImageID != nil && containsID(fromImageIDs, *a.CoverImageID) {
a.CoverImageID = idPointer(toImageID)
touched = true
}
if a.SocialImageID != nil && containsID(fromImageIDs, *a.SocialImageID) {
a.SocialImageID = idPointer(toImageID)
touched = true
}
if touched {
m.articles[id] = a
changed = append(changed, id)
}
}
return changed, nil
}
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
m.createCalled = true
if err := m.fail.trigger("Create"); err != nil {
return article.Article{}, err
}
if m.createAlwaysRaces {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, 0) {
return article.Article{}, article.ErrSlugTaken
}
m.nextID++
a.ID = m.nextID
m.articles[a.ID] = a
@@ -244,6 +342,16 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
if update.SiteID != nil {
a.SiteID = *update.SiteID
}
if update.Slug != nil {
a.Slug = *update.Slug
}
if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, id) {
return article.Article{}, article.ErrSlugTaken
}
applySeoUpdate(&a, update)
if update.AuthorIDs != nil {
m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
}
@@ -251,6 +359,33 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
return m.withAuthors(a), nil
}
func applySeoUpdate(a *article.Article, update article.Update) {
assign := func(target *string, value *string) {
if value != nil {
*target = *value
}
}
assign(&a.Excerpt, update.Excerpt)
assign(&a.SEOTitle, update.SEOTitle)
assign(&a.SEODescription, update.SEODescription)
assign(&a.CoverImageAlt, update.CoverImageAlt)
assign(&a.SocialTitle, update.SocialTitle)
assign(&a.SocialDescription, update.SocialDescription)
assign(&a.CanonicalURL, update.CanonicalURL)
if update.CoverImageID.Set {
a.CoverImageID = update.CoverImageID.Value
}
if update.SocialImageID.Set {
a.SocialImageID = update.SocialImageID.Value
}
if update.Noindex != nil {
a.Noindex = *update.Noindex
}
if update.SEODismissed != nil {
a.SEODismissed = append([]string{}, *update.SEODismissed...)
}
}
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
return article.Article{}, err
+1 -1
View File
@@ -30,7 +30,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -24,7 +24,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+3 -3
View File
@@ -426,7 +426,7 @@ func TestArticlesPaginationApiHttp(t *testing.T) {
online bool
}{{"Banana", true}, {"apple", false}, {"Cherry", true}, {"Plâtre fin", true}}
for _, entry := range titles {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online}); err != nil {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online, Slug: uniqueTestSlug()}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
@@ -481,7 +481,7 @@ func TestArticlesPaginationApiHttp(t *testing.T) {
title string
online bool
}{{"Alpha", true}, {"Bravo", false}, {"Charlie", true}, {"Delta", false}} {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online}); err != nil {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online, Slug: uniqueTestSlug()}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
@@ -548,7 +548,7 @@ func TestArticlesPaginationApiHttp(t *testing.T) {
db := openActivityDatabase(t)
repo := persistence.NewArticleRepository(db)
for _, title := range []string{"Alpha", "Beta", "Gamma"} {
if _, err := repo.Create(domainArticle.Article{Title: title, IsOnline: true}); err != nil {
if _, err := repo.Create(domainArticle.Article{Title: title, IsOnline: true, Slug: uniqueTestSlug()}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func mustCreateCreditedArticle(t *testing.T, db *gorm.DB, title string, authorID
for _, id := range authorIDs {
bylines = append(bylines, author.Byline{ID: id})
}
created, err := persistence.NewArticleRepository(db).Create(article.Article{Title: title, Body: "Body", Authors: bylines})
created, err := persistence.NewArticleRepository(db).Create(article.Article{Title: title, Body: "Body", Authors: bylines, Slug: uniqueTestSlug()})
if err != nil {
t.Fatalf("create article: %v", err)
}
+19 -2
View File
@@ -282,6 +282,22 @@ func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
return usage, nil
}
func (m *mockAuthorRepo) FindByAvatar(imageID uint32) ([]author.Author, error) {
found := []author.Author{}
for _, id := range m.sortedIDsAscending() {
if a := m.authors[id]; a.AvatarID != nil && *a.AvatarID == imageID {
found = append(found, a)
}
}
return found, nil
}
func (m *mockAuthorRepo) sortedIDsAscending() []uint32 {
ids := m.sortedIDsDescending()
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (m *mockAuthorRepo) ClearAvatar(imageID uint32) ([]uint32, error) {
cleared := []uint32{}
for _, id := range m.sortedIDsDescending() {
@@ -342,10 +358,11 @@ func newAuthorEnv(t *testing.T) *authorEnv {
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
c.ListAuthorsUseCase = useCases.NewListAuthorsUseCase(authors, p.users)
+8 -3
View File
@@ -523,7 +523,7 @@ func TestAuthorsBackendMediaUsage(t *testing.T) {
}
})
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
t.Run("The usage lookup stays an array and later gains additive keys", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
@@ -532,8 +532,13 @@ func TestAuthorsBackendMediaUsage(t *testing.T) {
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "author") {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil || rec.Code != http.StatusOK || len(items) != 2 {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
first := items[0]
if first["id"] != float64(1) || first["title"] != "Spring release" || first["published"] != true || first["type"] != "article" {
t.Errorf("the article item lost a key: %v", first)
}
})
}
+1 -1
View File
@@ -28,7 +28,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -281,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+2 -2
View File
@@ -114,7 +114,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors()),
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
@@ -131,7 +131,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors()),
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(e.images, e.variants, e.files, e.renderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(e.images, e.variants, e.files, e.renderer, serve),
+3 -3
View File
@@ -69,7 +69,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
@@ -77,9 +77,9 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), env.articles),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors()),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()),
+1 -1
View File
@@ -736,7 +736,7 @@ func TestMediaUsage(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
want := `[{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]`
want := `[{"type": "article", "id": 1, "title": "Kitchen Renovation", "published": true, "fields": ["body"]}, {"type": "article", "id": 2, "title": "Backyard Notes", "published": false, "fields": ["body"]}]`
expectJSONEqual(t, rec.Body.String(), want)
})
+3 -3
View File
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Delete(testCtx, created.ID); err != nil {
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Delete(testCtx, 7)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, 7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
@@ -99,7 +99,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Delete(testCtx, created.ID)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
+1 -1
View File
@@ -130,7 +130,7 @@ 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()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
+1 -1
View File
@@ -46,7 +46,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -28,7 +28,7 @@ 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()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+3 -3
View File
@@ -132,7 +132,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
@@ -145,9 +145,9 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors()),
}
}
+268
View File
@@ -0,0 +1,268 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"net/http"
"reflect"
"strings"
"testing"
)
func TestSeoDataActivity(t *testing.T) {
t.Run("A short-value SEO edit is recorded once after the change", func(t *testing.T) {
cases := []struct {
name string
before func(a *article.Article)
body string
field string
from string
to string
}{
{"slug", func(a *article.Article) { a.Slug = "spring" }, `{"slug": "spring-2026"}`, "slug", "spring", "spring-2026"},
{"canonical_url", func(a *article.Article) {}, `{"canonical_url": "https://www.example.com/a"}`, "canonical_url", "", "https://www.example.com/a"},
{"noindex", func(a *article.Article) {}, `{"noindex": true}`, "noindex", "false", "true"},
{"cover_image", func(a *article.Article) {}, `{"cover_image_id": 5}`, "cover_image", "", "cover.png"},
{"social_image", func(a *article.Article) { a.SocialImageID = idPointer(5) }, `{"social_image_id": null}`, "social_image", "cover.png", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
c.before(&stored)
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, c.body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionArticleEdit, audit.CategoryArticles, audit.OutcomeSuccess)
assertProducerTarget(t, event, audit.TargetArticle, "8", seoSpringTitle)
if event.Source != audit.SourceWeb {
t.Errorf("source = %s", event.Source)
}
want := `[{"field":"` + c.field + `","kind":"text","before":"` + c.from + `","after":"` + c.to + `"}]`
if got := changesJSON(t, event); got != want {
t.Errorf("changes = %s, expected %s", got, want)
}
})
}
})
t.Run("No success event is recorded when the update itself fails", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.articles.fail.failNext("Update")
rec := e.patch(t, seoSpringID, `{"slug": "fresh"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
for _, event := range e.events(t) {
if event.Outcome == audit.OutcomeSuccess {
t.Errorf("a success event exists for a failed update: %+v", event)
}
}
})
t.Run("A regenerated slug is recorded like an edited one", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom")
e.patch(t, seoSpringID, `{"slug": ""}`)
if got := changesJSON(t, e.single(t)); got != `[{"field":"slug","kind":"text","before":"custom","after":"spring-release"}]` {
t.Errorf("changes = %s", got)
}
})
t.Run("Free-text SEO fields are recorded as a size change, never as text", func(t *testing.T) {
cases := []struct {
field string
before int
after int
delta int
}{
{"excerpt", 0, 120, 120},
{"seo_title", 40, 55, 15},
{"seo_description", 160, 100, -60},
{"social_title", 0, 30, 30},
{"social_description", 10, 25, 15},
{"cover_image_alt", 0, 16, 16},
}
for _, c := range cases {
t.Run(c.field, func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
beforeText, afterText := strings.Repeat("Q", c.before), strings.Repeat("Z", c.after)
switch c.field {
case "excerpt":
stored.Excerpt = beforeText
case "seo_title":
stored.SEOTitle = beforeText
case "seo_description":
stored.SEODescription = beforeText
case "social_title":
stored.SocialTitle = beforeText
case "social_description":
stored.SocialDescription = beforeText
case "cover_image_alt":
stored.CoverImageAlt = beforeText
}
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, `{"`+c.field+`": "`+afterText+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
if len(event.Changes) != 1 || event.Changes[0].Field != c.field || event.Changes[0].Kind != audit.ChangeBody || event.Changes[0].Delta == nil || *event.Changes[0].Delta != c.delta || event.Changes[0].Before != nil || event.Changes[0].After != nil {
t.Errorf("changes = %s", changesJSON(t, event))
}
assertEventsHoldNothing(t, e.audit, "ZZZZZ", "QQQQQ")
feed := e.owner(t, http.MethodGet, "/v1/activity/?category=articles", "")
if feed.Code != http.StatusOK || strings.Contains(feed.Body.String(), "ZZZZZ") || strings.Contains(feed.Body.String(), "QQQQQ") {
t.Errorf("the activity response leaks the text: %d %s", feed.Code, feed.Body.String())
}
})
}
})
t.Run("Same-length free text with different content is still a change", func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
stored.SEOTitle = "AAAAA"
e.articles.articles[seoSpringID] = stored
e.patch(t, seoSpringID, `{"seo_title": "BBBBB"}`)
event := e.single(t)
if len(event.Changes) != 1 || event.Changes[0].Field != "seo_title" || event.Changes[0].Delta == nil || *event.Changes[0].Delta != 0 {
t.Errorf("changes = %s", changesJSON(t, event))
}
assertEventsHoldNothing(t, e.audit, "AAAAA", "BBBBB")
feed := e.owner(t, http.MethodGet, "/v1/activity/?category=articles", "")
if strings.Contains(feed.Body.String(), "AAAAA") || strings.Contains(feed.Body.String(), "BBBBB") {
t.Errorf("the activity response leaks the text: %s", feed.Body.String())
}
})
t.Run("A body and SEO edit together record both", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.patch(t, seoSpringID, `{"body": "New body text", "slug": "new"}`)
event := e.single(t)
fields := []string{}
for _, change := range event.Changes {
fields = append(fields, change.Field+":"+string(change.Kind))
}
if !reflect.DeepEqual(fields, []string{"body:body", "slug:text"}) {
t.Errorf("changes = %s", changesJSON(t, event))
}
assertEventsHoldNothing(t, e.audit, "New body text")
})
t.Run("Dismissing suggestions is not an edit", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["short"]}`)
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, []string{"short"}) {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A no-op SEO edit records nothing", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
same := `{"slug": "spring-full", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true}`
rec := e.patch(t, seoSpringID, same)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A refused slug is recorded as failed with a plain reason", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Taken", siteMainID, "taken")
rec := e.patch(t, seoSpringID, `{"slug": "taken"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionArticleEdit, audit.CategoryArticles, audit.OutcomeFailed)
assertProducerFailure(t, event, seoTakenReason)
})
t.Run("Nothing is recorded when there is nothing to attribute", func(t *testing.T) {
cases := []struct {
name string
userID uint32
path string
body string
}{
{"PATCH /v1/article/999 with an SEO field", apiKeyOwnerId, "/v1/article/999", `{"excerpt": "x"}`},
{"PATCH /v1/article/abc with an SEO field", apiKeyOwnerId, "/v1/article/abc", `{"excerpt": "x"}`},
{"PATCH /v1/article/8 with a bad slug", apiKeyOwnerId, "/v1/article/8", `{"slug": "Bad Slug"}`},
{"PATCH /v1/article/8 with a 301-character excerpt", apiKeyOwnerId, "/v1/article/8", `{"excerpt": "` + strings.Repeat("a", 301) + `"}`},
{"PATCH /v1/article/8 with an unknown cover image", apiKeyOwnerId, "/v1/article/8", `{"cover_image_id": 999}`},
{"PATCH /v1/article/8 with a bad canonical url", apiKeyOwnerId, "/v1/article/8", `{"canonical_url": "http://x.example"}`},
{"PATCH /v1/article/8 with no credentials", 0, "/v1/article/8", `{"excerpt": "x"}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.as(t, c.userID, http.MethodPatch, c.path, c.body)
e.noEvent(t)
})
}
})
t.Run("A failing audit repository never changes the response", func(t *testing.T) {
healthy := newSeoEnv(t)
healthy.seedSpring()
broken := newSeoEnv(t)
broken.seedSpring()
broken.audit.probe.appendErr = errDiskFull
want := healthy.patch(t, seoSpringID, `{"slug": "new"}`)
got := broken.patch(t, seoSpringID, `{"slug": "new"}`)
if got.Code != want.Code || stripTimestamps(got.Body.String()) != stripTimestamps(want.Body.String()) || broken.stored(seoSpringID).Slug != "new" {
t.Errorf("broken %d %s, healthy %d %s", got.Code, got.Body.String(), want.Code, want.Body.String())
}
})
t.Run("The activity API lists the SEO events", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.patch(t, seoSpringID, `{"slug": "new"}`)
rec := e.owner(t, http.MethodGet, "/v1/activity/?category=articles", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"action":"article.edit"`) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
func stripTimestamps(body string) string {
return producersTimestamps.ReplaceAllString(body, "")
}
+943
View File
@@ -0,0 +1,943 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func TestSeoDataArticlePatch(t *testing.T) {
t.Run("All SEO fields are set at once", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.authors.seed(5, "Camille", "camille")
e.articles.credit(seoSpringID, 5)
body := `{"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": ["cover_alt", "short"]}`
rec := e.patch(t, seoSpringID, body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
wantResponse := map[string]any{
"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.",
"cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.",
"social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true,
"seo_dismissed": []any{"cover_alt", "short"}, "title": seoSpringTitle, "body": "Body of Spring release", "site_id": float64(siteMainID),
}
for key, want := range wantResponse {
if !reflect.DeepEqual(decoded[key], want) {
t.Errorf("%s = %v, expected %v", key, decoded[key], want)
}
}
if authors := decoded["authors"].([]any); len(authors) != 1 {
t.Errorf("authors = %v", authors)
}
stored := e.stored(seoSpringID)
if stored.Slug != "spring-2026" || stored.Excerpt != "Short teaser." || stored.SEOTitle != "Spring release notes" || stored.SEODescription != "Everything new this spring." ||
stored.CoverImageID == nil || *stored.CoverImageID != 5 || stored.CoverImageAlt != "A field of tulips" || stored.SocialTitle != "Spring is here" ||
stored.SocialDescription != "Read the notes." || stored.SocialImageID == nil || *stored.SocialImageID != 6 || stored.CanonicalURL != "https://www.example.com/spring" ||
!stored.Noindex || !reflect.DeepEqual(stored.SEODismissed, []string{"cover_alt", "short"}) {
t.Errorf("stored = %+v", stored)
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"seo_title": "New title"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
want := before
want.SEOTitle = "New title"
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, want) {
t.Errorf("after = %+v, expected %+v", after, want)
}
})
t.Run("Text fields and the flag are cleared with empty values", func(t *testing.T) {
cases := []struct {
body string
check func(a article.Article) bool
}{
{`{"excerpt": ""}`, func(a article.Article) bool { return a.Excerpt == "" }},
{`{"seo_title": ""}`, func(a article.Article) bool { return a.SEOTitle == "" }},
{`{"seo_description": ""}`, func(a article.Article) bool { return a.SEODescription == "" }},
{`{"cover_image_alt": ""}`, func(a article.Article) bool { return a.CoverImageAlt == "" }},
{`{"social_title": ""}`, func(a article.Article) bool { return a.SocialTitle == "" }},
{`{"social_description": ""}`, func(a article.Article) bool { return a.SocialDescription == "" }},
{`{"canonical_url": ""}`, func(a article.Article) bool { return a.CanonicalURL == "" }},
{`{"noindex": false}`, func(a article.Article) bool { return !a.Noindex }},
{`{"seo_dismissed": []}`, func(a article.Article) bool { return len(a.SEODismissed) == 0 }},
{`{"cover_image_id": null}`, func(a article.Article) bool { return a.CoverImageID == nil }},
{`{"social_image_id": null}`, func(a article.Article) bool { return a.SocialImageID == nil }},
}
for _, c := range cases {
t.Run(c.body, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
rec := e.patch(t, seoSpringID, c.body)
if rec.Code != http.StatusOK || !c.check(e.stored(seoSpringID)) {
t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID))
}
})
}
})
t.Run("Boundaries are accepted", func(t *testing.T) {
cases := []struct{ name, body string }{
{"excerpt 300 characters", `{"excerpt": "` + repeated("a", 300) + `"}`},
{"excerpt 300 multi-byte characters", `{"excerpt": "` + repeated("é", 300) + `"}`},
{"seo_title 120 characters", `{"seo_title": "` + repeated("a", 120) + `"}`},
{"seo_description 320 characters", `{"seo_description": "` + repeated("a", 320) + `"}`},
{"cover_image_alt 200 characters", `{"cover_image_alt": "` + repeated("a", 200) + `"}`},
{"social_title 120 characters", `{"social_title": "` + repeated("a", 120) + `"}`},
{"social_description 320 characters", `{"social_description": "` + repeated("a", 320) + `"}`},
{"slug 100 characters", `{"slug": "` + repeated("a", 100) + `"}`},
{"canonical_url with query and fragment", `{"canonical_url": "https://example.com/a?b=1#c"}`},
{"canonical_url 2048 characters", `{"canonical_url": "https://example.com/` + repeated("a", 2048-len("https://example.com/")) + `"}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, c.body)
if rec.Code != http.StatusOK {
t.Errorf("status %d: %.200s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("Fields are validated", func(t *testing.T) {
const validator = "validator"
longURL := `{"canonical_url": "https://example.com/` + repeated("a", 2049-len("https://example.com/")) + `"}`
cases := []struct {
name string
body string
status int
text string
}{
{"excerpt 301", `{"excerpt": "` + repeated("a", 301) + `"}`, 400, validator},
{"seo_title 121", `{"seo_title": "` + repeated("a", 121) + `"}`, 400, validator},
{"seo_description 321", `{"seo_description": "` + repeated("a", 321) + `"}`, 400, validator},
{"cover_image_alt 201", `{"cover_image_alt": "` + repeated("a", 201) + `"}`, 400, validator},
{"social_title 121", `{"social_title": "` + repeated("a", 121) + `"}`, 400, validator},
{"social_description 321", `{"social_description": "` + repeated("a", 321) + `"}`, 400, validator},
{"canonical_url 2049", longURL, 400, validator},
{"slug uppercase", `{"slug": "Spring"}`, 400, "invalid_slug"},
{"slug underscore", `{"slug": "spring_2026"}`, 400, "invalid_slug"},
{"slug leading hyphen", `{"slug": "-spring"}`, 400, "invalid_slug"},
{"slug double hyphen", `{"slug": "spring--2026"}`, 400, "invalid_slug"},
{"slug 101", `{"slug": "` + repeated("a", 101) + `"}`, 400, "invalid_slug"},
{"canonical http", `{"canonical_url": "http://example.com/a"}`, 422, "invalid_url"},
{"canonical relative", `{"canonical_url": "/relative"}`, 422, "invalid_url"},
{"canonical bare host", `{"canonical_url": "example.com"}`, 422, "invalid_url"},
{"canonical no host", `{"canonical_url": "https://"}`, 422, "invalid_url"},
{"unknown dismissed code", `{"seo_dismissed": ["nope"]}`, 400, validator},
{"unknown cover image", `{"cover_image_id": 999}`, 400, "image_not_found"},
{"unknown social image", `{"social_image_id": 999}`, 400, "image_not_found"},
{"cover image zero", `{"cover_image_id": 0}`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, c.body)
if c.text == validator {
expectStatusAndValidatorText(t, rec, c.status)
} else {
expectStatusAndText(t, rec, c.status, c.text)
}
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
t.Errorf("the article changed: %+v", after)
}
e.noEvent(t)
})
}
})
t.Run("A wrong type or null on other fields is refused", func(t *testing.T) {
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"slug": null}`, `{"excerpt": null}`, `{"noindex": null}`, `{"seo_dismissed": null}`, `{"noindex": "yes"}`, `{"excerpt": 5}`, `{"cover_image_id": "a"}`, `{"seo_dismissed": "x"}`} {
t.Run(body, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, body)
expectStatusAndText(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
t.Errorf("the article changed: %+v", after)
}
})
}
})
t.Run("Duplicate dismissed codes are collapsed", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["short", "cover_alt", "short"]}`)
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, []string{"short", "cover_alt"}) {
t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed)
}
})
t.Run("Every known dismissed code is accepted", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
codes := article.DismissCodes()
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["`+strings.Join(codes, `", "`)+`"]}`)
if len(codes) != 11 || rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, codes) {
t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed)
}
})
t.Run("A refused update changes nothing else", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}`)
expectStatusAndText(t, rec, http.StatusUnprocessableEntity, "invalid_url")
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
t.Errorf("the article changed: %+v", after)
}
})
t.Run("SEO fields and the other fields change together", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.authors.seed(5, "Camille", "camille")
rec := e.patch(t, seoSpringID, `{"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}`)
stored := e.stored(seoSpringID)
if rec.Code != http.StatusOK || stored.Body != "New" || stored.SiteID != 2 || stored.Excerpt != "Teaser" || !reflect.DeepEqual(e.articles.credits[seoSpringID], []uint32{5}) {
t.Errorf("status %d, stored %+v, credits %v", rec.Code, stored, e.articles.credits[seoSpringID])
}
})
t.Run("An update without SEO fields keeps them", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"body": "New"}`)
want := before
want.Body = "New"
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID), want) {
t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID))
}
})
t.Run("Any account that can edit the article edits its SEO fields", func(t *testing.T) {
callers := []struct {
name string
send func(e *seoEnv, t *testing.T) int
}{
{"the owner's jwt cookie", func(e *seoEnv, t *testing.T) int {
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
{"a verified admin's jwt cookie", func(e *seoEnv, t *testing.T) int {
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
{"a verified account with another role", func(e *seoEnv, t *testing.T) int {
return e.as(t, seoEditorID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
{"a valid API key", func(e *seoEnv, t *testing.T) int {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
}},
}
for _, c := range callers {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
status := c.send(e, t)
if status != http.StatusOK || e.stored(seoSpringID).Excerpt != "Teaser" {
t.Errorf("status %d, excerpt %q", status, e.stored(seoSpringID).Excerpt)
}
})
}
})
t.Run("Without a verified account the article is not edited", func(t *testing.T) {
callers := []struct {
name string
userID uint32
status int
}{
{"an unverified account's jwt cookie", apiKeyQuinnId, http.StatusForbidden},
{"no credentials", 0, http.StatusUnauthorized},
}
for _, c := range callers {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
before := e.stored(seoSpringID)
rec := e.as(t, c.userID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`)
if rec.Code != c.status || !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("status %d", rec.Code)
}
e.noEvent(t)
})
}
})
t.Run("A missing article is not found", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.patch(t, 999, `{"excerpt": "x"}`)
expectStatusAndText(t, rec, http.StatusNotFound, "Article not found")
e.noEvent(t)
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/article/abc", `{"excerpt": "x"}`)
expectStatusAndText(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
e.noEvent(t)
})
t.Run("noindex is only a flag", func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
stored.IsOnline = true
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, `{"noindex": true}`)
anonymous := e.as(t, 0, http.MethodGet, "/v1/article/8", "")
if rec.Code != http.StatusOK || !e.stored(seoSpringID).IsOnline {
t.Fatalf("status %d, online %v", rec.Code, e.stored(seoSpringID).IsOnline)
}
if anonymous.Code != http.StatusOK || decodeSeoObject(t, anonymous)["noindex"] != true {
t.Errorf("anonymous read: %d %s", anonymous.Code, anonymous.Body.String())
}
})
t.Run("The cover alt is independent of the cover", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"cover_image_alt": "A field"}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["cover_image_id"] != nil || decoded["cover_image_alt"] != "A field" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
func TestSeoDataArticleReads(t *testing.T) {
allFields := []string{"slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
otherTenFields := []string{"seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
t.Run("Single-article reads carry all twelve fields", func(t *testing.T) {
requests := []struct{ method, path string }{
{http.MethodGet, "/v1/article/8"},
{http.MethodPatch, "/v1/article/8"},
{http.MethodPatch, "/v1/article/8/publish"},
{http.MethodPatch, "/v1/article/8/unpublish"},
}
for _, request := range requests {
t.Run(request.method+" "+request.path, func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
body := ""
if request.method == http.MethodPatch && !strings.Contains(request.path, "publish") {
body = `{"body": "same"}`
}
rec := e.owner(t, request.method, request.path, body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
stored := e.stored(seoSpringID)
want := map[string]any{
"slug": stored.Slug, "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.",
"cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.",
"social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": []any{"cover_alt", "short"},
}
for _, key := range allFields {
if !reflect.DeepEqual(decoded[key], want[key]) {
t.Errorf("%s = %v, expected %v", key, decoded[key], want[key])
}
}
})
}
})
t.Run("A new article has the defaults", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
want := map[string]any{"slug": "spring-release", "excerpt": "", "seo_title": "", "seo_description": "", "cover_image_id": nil, "cover_image_alt": "", "social_title": "", "social_description": "", "social_image_id": nil, "canonical_url": "", "noindex": false, "seo_dismissed": []any{}}
for key, value := range want {
if !reflect.DeepEqual(decoded[key], value) {
t.Errorf("%s = %#v, expected %#v", key, decoded[key], value)
}
}
})
t.Run("The list carries the excerpt and the slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoSpringID)
stored := e.stored(seoSpringID)
stored.Slug = "spring-release"
e.articles.articles[seoSpringID] = stored
e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes")
rec := e.owner(t, http.MethodGet, "/v1/article/", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeSeoObject(t, rec)
items := decoded["items"].([]any)
if len(items) != 2 || decoded["total"] != float64(2) || decoded["page"] != float64(1) || decoded["size"] != float64(25) || decoded["counts"] == nil {
t.Fatalf("list = %v", decoded)
}
for _, raw := range items {
item := raw.(map[string]any)
if _, ok := item["excerpt"]; !ok {
t.Errorf("item without excerpt: %v", item)
}
if _, ok := item["slug"]; !ok {
t.Errorf("item without slug: %v", item)
}
for _, key := range otherTenFields {
if _, present := item[key]; present {
t.Errorf("item carries %s: %v", key, item)
}
}
if item["id"] == float64(seoSpringID) && (item["excerpt"] != "Short teaser." || item["slug"] != "spring-release") {
t.Errorf("article 8 item = %v", item)
}
}
})
t.Run("Anonymous readers see the SEO fields of online articles", func(t *testing.T) {
e := newSeoEnv(t)
e.seedFullyOptimised(seoOtherID)
online := e.stored(seoOtherID)
online.IsOnline = true
e.articles.articles[seoOtherID] = online
rec := e.as(t, 0, http.MethodGet, "/v1/article/9", "")
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["excerpt"] != "Short teaser." || decoded["canonical_url"] != "https://www.example.com/spring" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("An offline article is still not found publicly", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.as(t, 0, http.MethodGet, "/v1/article/8", "")
unverified := e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/8", "")
expectStatusAndText(t, rec, http.StatusNotFound, "Article not found")
expectStatusAndText(t, unverified, http.StatusNotFound, "Article not found")
})
t.Run("The list for anonymous callers shows online articles only", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
online := e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes")
online.IsOnline = true
online.Excerpt = "Leaves."
e.articles.articles[seoOtherID] = online
rec := e.as(t, 0, http.MethodGet, "/v1/article/", "")
items := decodeSeoObject(t, rec)["items"].([]any)
if rec.Code != http.StatusOK || len(items) != 1 {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
item := items[0].(map[string]any)
if item["excerpt"] != "Leaves." || item["slug"] != "autumn-notes" {
t.Errorf("item = %v", item)
}
})
}
func TestSeoDataSlugGeneration(t *testing.T) {
t.Run("The slug is generated from the title at creation", func(t *testing.T) {
for _, c := range []struct{ title, slug string }{
{"Spring release", "spring-release"},
{"Été à Paris", "ete-a-paris"},
{"Dr. O'Neil Smith", "dr-o-neil-smith"},
{"!!!", "article"},
} {
t.Run(c.title, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, c.title)+`, "body": "x"}`)
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != c.slug {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("A long title gives a slug of at most 100 characters", func(t *testing.T) {
e := newSeoEnv(t)
title := strings.TrimSpace(repeated("longword ", 22))
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`)
slug := decodeSeoObject(t, rec)["slug"].(string)
if rec.Code != http.StatusCreated || len(slug) > 100 || strings.HasSuffix(slug, "-") || !article.IsValidSlug(slug) {
t.Errorf("status %d slug %q (%d)", rec.Code, slug, len(slug))
}
})
t.Run("A generated slug that is taken on the site is de-duplicated silently", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "One", siteMainID, "spring-release")
e.seedArticle(2, "Two", siteMainID, "spring-release-2")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != "spring-release-3" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("A title without letters or digits is de-duplicated too", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "One", siteMainID, "article")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "!!!", "body": "x"}`)
if decodeSeoObject(t, rec)["slug"] != "article-2" {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("The suffix still fits the slug limit at creation", func(t *testing.T) {
e := newSeoEnv(t)
title := repeated("a", 100)
e.seedArticle(1, "One", siteMainID, title)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`)
slug := decodeSeoObject(t, rec)["slug"].(string)
if rec.Code != http.StatusCreated || len(slug) != 100 || !strings.HasSuffix(slug, "-2") {
t.Errorf("status %d slug %q", rec.Code, slug)
}
})
t.Run("The same title on another site keeps the plain slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "One", siteMainID, "spring-release")
e.sites.seed(siteShopID, "Shop", "shop", true)
defaultSite := e.sites.sites[siteMainID]
defaultSite.IsDefault = false
e.sites.sites[siteMainID] = defaultSite
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
decoded := decodeSeoObject(t, rec)
if decoded["slug"] != "spring-release" || decoded["site_id"] != float64(siteShopID) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("A new article is created on the default site, so uniqueness uses that site", func(t *testing.T) {
e := newSeoEnv(t)
e.sites.seed(siteMainID, "Main", "main", false)
e.sites.seed(siteShopID, "Shop", "shop", true)
e.seedArticle(1, "One", siteShopID, "spring-release")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
if decodeSeoObject(t, rec)["slug"] != "spring-release-2" {
t.Errorf("body %s", rec.Body.String())
}
})
}
func TestSeoDataSlugEditing(t *testing.T) {
assertConflict := func(t *testing.T, rec interface {
Result() *http.Response
}, status int, suggestion string) {
t.Helper()
response := rec.Result()
if response.StatusCode != status || response.Header.Get(seoSlugHeader) != suggestion {
t.Errorf("status %d suggestion %q, expected %d %q", response.StatusCode, response.Header.Get(seoSlugHeader), status, suggestion)
}
}
t.Run("The same slug is allowed on two sites", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
e.seedArticle(seoOtherID, "Other", siteShopID, "other")
rec := e.patch(t, seoOtherID, `{"slug": "spring"}`)
if rec.Code != http.StatusOK || e.stored(seoOtherID).Slug != "spring" {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The slug is editable at any time", func(t *testing.T) {
e := newSeoEnv(t)
stored := e.seedSpring()
stored.IsOnline = true
e.articles.articles[seoSpringID] = stored
rec := e.patch(t, seoSpringID, `{"slug": "spring-2026"}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring-2026" || !e.stored(seoSpringID).IsOnline {
t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID))
}
})
t.Run("Keeping the own slug is not a collision", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"slug": "spring-release", "excerpt": "x"}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Excerpt != "x" {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A taken slug is refused with a suggestion", func(t *testing.T) {
for _, c := range []struct {
taken []string
suggestion string
}{
{[]string{"spring"}, "spring-2"},
{[]string{"spring", "spring-2"}, "spring-3"},
{[]string{"spring", "spring-2", "spring-3"}, "spring-4"},
} {
t.Run(strings.Join(c.taken, ","), func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
for index, slug := range c.taken {
e.seedArticle(uint32(20+index), "Taken "+slug, siteMainID, slug)
}
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, c.suggestion)
if !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("the article changed")
}
})
}
})
t.Run("The suggestion fits the slug limit", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
long := repeated("a", 100)
e.seedArticle(20, "Long", siteMainID, long)
rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`)
suggestion := rec.Result().Header.Get(seoSlugHeader)
if rec.Code != http.StatusConflict || len(suggestion) != 100 || !strings.HasSuffix(suggestion, "-2") {
t.Errorf("status %d suggestion %q", rec.Code, suggestion)
}
})
t.Run("The suggestion skips every taken suffix and still fits 100 characters", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
long := repeated("a", 100)
e.seedArticle(20, "Long", siteMainID, long)
e.seedArticle(21, "Long 2", siteMainID, long[:98]+"-2")
e.seedArticle(22, "Long 3", siteMainID, long[:98]+"-3")
rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`)
suggestion := rec.Result().Header.Get(seoSlugHeader)
if rec.Code != http.StatusConflict || suggestion != long[:98]+"-4" || len(suggestion) != 100 {
t.Errorf("status %d suggestion %q", rec.Code, suggestion)
}
})
t.Run("The suggestion ignores the article itself", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring-2")
e.seedArticle(seoOtherID, "Other", siteMainID, "spring")
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
assertConflict(t, rec, http.StatusConflict, "spring-2")
})
t.Run("The suggestion header is only set on the slug conflict", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Taken", siteMainID, "taken")
requests := []struct{ method, path, body string }{
{http.MethodPatch, "/v1/article/8", `{"slug": "fresh"}`},
{http.MethodPatch, "/v1/article/8", `{"slug": "Bad Slug"}`},
{http.MethodPatch, "/v1/article/999", `{"slug": "fresh"}`},
{http.MethodGet, "/v1/article/8", ""},
{http.MethodGet, "/v1/article/", ""},
{http.MethodPost, "/v1/article", `{"title": "Taken", "body": "x"}`},
}
for _, request := range requests {
rec := e.owner(t, request.method, request.path, request.body)
if header := rec.Result().Header.Get(seoSlugHeader); header != "" {
t.Errorf("%s %s answered %d with %s: %q", request.method, request.path, rec.Code, seoSlugHeader, header)
}
}
})
t.Run("The repository can report a race", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.articles.reportSlugRace = true
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"slug": "fresh-looking"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, "fresh-looking-2")
if !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("the article changed")
}
})
t.Run("An empty slug regenerates it from the title", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom-address")
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug)
}
})
t.Run("Regeneration follows the creation rule", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "!!!", siteMainID, "custom")
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "article" {
t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug)
}
})
t.Run("Regenerating to the slug it already has is a no-op", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
t.Errorf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("Regeneration that collides is refused, never silently suffixed", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoOtherID, "Other", siteMainID, seoSpringSlug)
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom")
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, "spring-release-2")
if e.stored(seoSpringID).Slug != "custom" {
t.Errorf("slug %q", e.stored(seoSpringID).Slug)
}
})
t.Run("Regeneration combined with a move uses the target site", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug)
rec := e.patch(t, seoSpringID, `{"site_id": 2, "slug": ""}`)
assertConflict(t, rec, http.StatusConflict, "spring-release-2")
})
t.Run("Moving to another site re-checks the slug on the target", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
e.seedArticle(seoOtherID, "Other", siteShopID, "spring")
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
assertConflict(t, rec, http.StatusConflict, "spring-2")
if stored := e.stored(seoSpringID); stored.SiteID != siteMainID || stored.Slug != "spring" {
t.Errorf("stored %+v", stored)
}
})
t.Run("Moving to a site where the slug is free keeps the slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if stored := e.stored(seoSpringID); rec.Code != http.StatusOK || stored.SiteID != 2 || stored.Slug != seoSpringSlug {
t.Errorf("status %d stored %+v", rec.Code, stored)
}
})
t.Run("Moving and changing the slug together checks the new slug on the target", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(seoOtherID, "Other", siteMainID, "elsewhere")
e.seedArticle(30, "On shop", siteShopID, "spring")
moved := e.patch(t, seoSpringID, `{"site_id": 2, "slug": "autumn"}`)
refused := e.patch(t, seoOtherID, `{"site_id": 2, "slug": "spring"}`)
if moved.Code != http.StatusOK || e.stored(seoSpringID).Slug != "autumn" {
t.Errorf("move status %d", moved.Code)
}
assertConflict(t, refused, http.StatusConflict, "spring-2")
})
t.Run("A refused move changes no SEO field", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug)
before := e.stored(seoSpringID)
rec := e.patch(t, seoSpringID, `{"site_id": 2, "excerpt": "new"}`)
if rec.Code != http.StatusConflict || !reflect.DeepEqual(e.stored(seoSpringID), before) {
t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID))
}
})
t.Run("A freed slug can be reused", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "autumn")
e.seedArticle(seoOtherID, "Other", siteMainID, "spring")
deleted := e.owner(t, http.MethodDelete, "/v1/article/9", "")
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
if deleted.Code != http.StatusNoContent || rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring" {
t.Errorf("delete %d, patch %d", deleted.Code, rec.Code)
}
})
t.Run("Publishing and unpublishing never change the slug", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
published := e.owner(t, http.MethodPatch, "/v1/article/8/publish", "")
unpublished := e.owner(t, http.MethodPatch, "/v1/article/8/unpublish", "")
if published.Code != http.StatusOK || unpublished.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
t.Errorf("publish %d unpublish %d slug %q", published.Code, unpublished.Code, e.stored(seoSpringID).Slug)
}
})
}
func TestSeoDataSlugSuggestionUseCase(t *testing.T) {
t.Run("The suggestion is the first free suffix", func(t *testing.T) {
for taken := 0; taken < 6; taken++ {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Base", siteMainID, "spring")
for attempt := 2; attempt < 2+taken; attempt++ {
e.seedArticle(uint32(20+attempt), "Suffix "+strconv.Itoa(attempt), siteMainID, "spring-"+strconv.Itoa(attempt))
}
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
want := "spring-" + strconv.Itoa(2+taken)
if got := rec.Result().Header.Get(seoSlugHeader); got != want {
t.Errorf("with %d suffixes taken: %q, expected %q", taken, got, want)
}
}
})
t.Run("The suggestion is computed on the target site of a move", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
e.seedArticle(20, "Main also", siteMainID, "spring-2")
e.seedArticle(21, "Shop", siteShopID, "spring")
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if got := rec.Result().Header.Get(seoSlugHeader); got != "spring-2" {
t.Errorf("suggestion %q, expected the slug that is free on the shop", got)
}
})
t.Run("A suggestion lookup failure is an internal error without header", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(20, "Taken", siteMainID, "taken")
e.articles.fail.failNext("SlugTaken")
rec := e.patch(t, seoSpringID, `{"slug": "fresh"}`)
if rec.Code != http.StatusInternalServerError || rec.Result().Header.Get(seoSlugHeader) != "" {
t.Errorf("status %d", rec.Code)
}
})
}
func TestSeoDataDismissCodes(t *testing.T) {
t.Run("The dismissed codes are the eleven suggestion codes", func(t *testing.T) {
if got := fmt.Sprint(article.DismissCodes()); got != "[title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short]" {
t.Errorf("codes = %s", got)
}
})
}
+194
View File
@@ -0,0 +1,194 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/slug"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"reflect"
"strings"
"testing"
"gorm.io/gorm"
)
func legacyWithSlugs(t *testing.T, slugs map[uint32]string, rows ...seoLegacyRow) *gorm.DB {
t.Helper()
db := openPreSliceDatabase(t, rows...)
migrateSeo(t, db)
if err := db.Exec("DROP INDEX idx_articles_site_slug").Error; err != nil {
t.Fatal(err)
}
for id, value := range slugs {
if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", value).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestSeoDataCleanerBackfillRepair(t *testing.T) {
rows := func(count int) []seoLegacyRow {
result := []seoLegacyRow{}
for id := 1; id <= count; id++ {
result = append(result, seoLegacyRow{id: uint32(id), siteID: 1, title: "Row"})
}
return result
}
t.Run("Live duplicates keep the lowest id and the others are suffixed", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news", 3: "news", 4: "news-2", 5: "news"}, rows(5)...)
if err := db.Model(&entity.Article{}).Where("id = ?", 5).UpdateColumn("site_id", 2).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
want := map[uint32]string{1: "news", 2: "news-3", 3: "news-4", 4: "news-2", 5: "news"}
if got := storedSlugs(t, db); !reflect.DeepEqual(got, want) {
t.Errorf("slugs = %v", got)
}
if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
t.Error("the index is missing")
}
})
t.Run("A soft-deleted row sharing a slug with a live row is tombstoned", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news"}, rows(2)...)
if err := db.Delete(&entity.Article{}, 1).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: article.Tombstone(1), 2: "news"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("Every soft-deleted row is tombstoned whatever its slug", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "custom", 2: "", 3: "other"}, rows(3)...)
if err := db.Delete(&entity.Article{}, []uint32{1, 2}).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "#deleted-1", 2: "#deleted-2", 3: "other"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("A second run changes nothing", func(t *testing.T) {
db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news", 3: "gone"}, rows(3)...)
if err := db.Delete(&entity.Article{}, 3).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
first := storedSlugs(t, db)
migrateSeo(t, db)
if second := storedSlugs(t, db); !reflect.DeepEqual(first, second) || first[2] != "news-2" || first[3] != "#deleted-3" {
t.Errorf("first %v second %v", first, second)
}
})
}
func TestSeoDataCleanerBackfillAlgorithmIsFrozen(t *testing.T) {
titles := []string{"Café Déjà Vu", "Straße & Æon", "!!!", "Ça va?", strings.Repeat("a", 120), strings.Repeat("ab-", 40), "日本語", " Spaced Out ", "Ø Ł Ñ", "Hello, World 2024"}
legacy := []seoLegacyRow{}
for index, title := range titles {
legacy = append(legacy, seoLegacyRow{id: uint32(index + 1), siteID: 1, title: title})
}
db := openPreSliceDatabase(t, legacy...)
migrateSeo(t, db)
want := map[uint32]string{
1: "cafe-deja-vu", 2: "strasse-aeon", 3: "article", 4: "ca-va", 5: strings.Repeat("a", 100),
6: strings.TrimRight(strings.Repeat("ab-", 40)[:100], "-"), 7: "article-2", 8: "spaced-out", 9: "o-l-n", 10: "hello-world-2024",
}
if got := storedSlugs(t, db); !reflect.DeepEqual(got, want) {
t.Errorf("slugs = %v", got)
}
}
func TestSeoDataCleanerTombstone(t *testing.T) {
if article.Tombstone(7) != "#deleted-7" || slug.IsValid(article.Tombstone(7)) || slug.IsValid(article.Tombstone(123456)) {
t.Errorf("tombstone %q", article.Tombstone(7))
}
db := openPreSliceDatabase(t)
migrateSeo(t, db)
articles := persistence.NewArticleRepository(db)
created, err := articles.Create(article.Article{Title: "Gone", SiteID: 1, Slug: "gone", SEODismissed: []string{}})
if err != nil {
t.Fatal(err)
}
if err := articles.Delete(created.ID); err != nil {
t.Fatal(err)
}
if got := storedSlugs(t, db)[created.ID]; got != article.Tombstone(created.ID) {
t.Errorf("slug after delete %q", got)
}
}
func TestSeoDataCleanerEmptyValues(t *testing.T) {
t.Run("Empty SEO text and site text insert after two migrations", func(t *testing.T) {
db := openPreSliceDatabase(t)
migrateSeo(t, db)
migrateSeo(t, db)
sites := persistence.NewSiteRepository(db)
articles := persistence.NewArticleRepository(db)
created, siteErr := sites.Create(site.Site{Name: "Blank", Key: "blank", BaseURL: "https://blank.example.com", DefaultRobots: site.RobotsIndex, TitleTemplate: site.DefaultTitleTemplate})
stored, articleErr := articles.Create(article.Article{Title: "Blank", SiteID: created.ID, Slug: "blank", SEODismissed: []string{}})
if siteErr != nil || articleErr != nil {
t.Fatalf("site %v article %v", siteErr, articleErr)
}
reloaded, err := sites.Get(created.ID)
found, foundErr := articles.Get(stored.ID)
if err != nil || reloaded.SiteName != "" || reloaded.DefaultDescription != "" || foundErr != nil || found.SEOTitle != "" || found.SEODescription != "" || found.Excerpt != "" {
t.Errorf("site %+v (%v) article %+v (%v)", reloaded, err, found, foundErr)
}
})
t.Run("An empty stored title template reads as the default", func(t *testing.T) {
db := openPreSliceDatabase(t)
migrateSeo(t, db)
sites := persistence.NewSiteRepository(db)
created := mustCreateSite(t, sites, "Blank", "blank", false)
if err := db.Model(&entity.Site{}).Where("id = ?", created.ID).UpdateColumn("title_template", "").Error; err != nil {
t.Fatal(err)
}
got, err := sites.Get(created.ID)
listed, listErr := sites.List()
if err != nil || got.TitleTemplate != site.DefaultTitleTemplate || listErr != nil {
t.Fatalf("got %q %v %v", got.TitleTemplate, err, listErr)
}
for _, entry := range listed {
if entry.TitleTemplate == "" {
t.Errorf("listed site %d has an empty template", entry.ID)
}
}
})
}
func TestSeoDataCleanerSiteKeyRace(t *testing.T) {
db := openPreSliceDatabase(t)
migrateSeo(t, db)
sites := persistence.NewSiteRepository(db)
mustCreateSite(t, sites, "Shop", "shop", false)
_, err := sites.Create(site.Site{Name: "Again", Key: "shop", BaseURL: "https://again.example.com", DefaultRobots: site.RobotsIndex})
if !errors.Is(err, site.ErrSiteKeyTaken) {
t.Errorf("err = %v", err)
}
}
+353
View File
@@ -0,0 +1,353 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"log"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"testing"
)
type seoAlwaysRacingArticleRepo struct {
*mockArticleRepo
createCalls int
}
func (r *seoAlwaysRacingArticleRepo) Create(a article.Article) (article.Article, error) {
r.createCalls++
return article.Article{}, article.ErrSlugTaken
}
func TestSeoDataCleanerUsageOrdering(t *testing.T) {
t.Run("Usage lists articles then sites, each ordered by id", func(t *testing.T) {
e := newSeoEnv(t)
for _, id := range []uint32{11, 9, 12, 8, 10, 19, 15, 13, 17, 14, 18, 16} {
e.seedArticle(id, "Article", siteMainID, "article-"+strconv.Itoa(int(id)))
e.updateArticle(id, func(a *article.Article) { a.CoverImageID = idPointer(5) })
}
e.sites.seed(3, "Zeta", "zeta", true)
e.sites.seed(siteMainID, "Main", "main", false)
for _, id := range []uint32{3, siteShopID, siteMainID} {
s := e.sites.sites[id]
s.DefaultSocialImageID = idPointer(5)
e.sites.sites[id] = s
}
items := e.usage(t, 5)
got := []string{}
ids := []float64{}
for _, item := range items {
got = append(got, item["type"].(string))
ids = append(ids, item["id"].(float64))
}
wantTypes := []string{}
wantIDs := []float64{}
for id := 8; id <= 19; id++ {
wantTypes = append(wantTypes, "article")
wantIDs = append(wantIDs, float64(id))
}
wantTypes = append(wantTypes, "site", "site", "site")
wantIDs = append(wantIDs, siteMainID, siteShopID, 3)
if !reflect.DeepEqual(got, wantTypes) || !reflect.DeepEqual(ids, wantIDs) {
t.Errorf("types %v ids %v", got, ids)
}
})
}
func TestSeoDataCleanerSlugRaces(t *testing.T) {
t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) {
e := newSeoEnv(t)
racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles}
create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
_, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Title: "Racing", Body: "x"})
if !errors.Is(err, article.ErrSlugTaken) || racing.createCalls != 5 {
t.Errorf("err %v after %d attempts", err, racing.createCalls)
}
})
t.Run("A race while moving to another site suggests a free slug there", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.articles.reportSlugRace = true
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if rec.Code != http.StatusConflict || rec.Result().Header.Get(seoSlugHeader) != "spring-release-2" {
t.Errorf("status %d suggestion %q", rec.Code, rec.Result().Header.Get(seoSlugHeader))
}
})
t.Run("Moving to a site where the slug is taken conflicts", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.seedArticle(1, "Other", siteShopID, seoSpringSlug)
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
if rec.Code != http.StatusConflict || rec.Result().Header.Get(seoSlugHeader) != "spring-release-2" || e.stored(seoSpringID).SiteID != siteMainID {
t.Errorf("status %d suggestion %q site %d", rec.Code, rec.Result().Header.Get(seoSlugHeader), e.stored(seoSpringID).SiteID)
}
})
}
func TestSeoDataCleanerCreateErrors(t *testing.T) {
t.Run("A missing default site is a 404, not a 500", func(t *testing.T) {
e := newSeoEnv(t)
e.sites.fail.failNextWith("GetDefault", site.ErrSiteNotFound)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusNotFound, apperr.SiteNotFoundErrorMessage)
})
t.Run("Exhausted slug retries are a 409", func(t *testing.T) {
e := newSeoEnv(t)
e.articles.createAlwaysRaces = true
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
})
t.Run("An unknown failure keeps the generic create message", func(t *testing.T) {
e := newSeoEnv(t)
e.articles.fail.failNext("Create")
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusInternalServerError, "Failed to create article")
})
}
func TestSeoDataCleanerFreeSlugCap(t *testing.T) {
seedTaken := func(e *seoEnv, upTo int) {
e.seedArticle(1, "Spring release", siteMainID, seoSpringSlug)
for attempt := 2; attempt <= upTo; attempt++ {
e.seedArticle(uint32(attempt), "Spring release", siteMainID, seoSpringSlug+"-"+strconv.Itoa(attempt))
}
}
t.Run("The last attempt is still used", func(t *testing.T) {
e := newSeoEnv(t)
seedTaken(e, 99)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != "spring-release-100" {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("Creating gives up when every candidate is taken", func(t *testing.T) {
e := newSeoEnv(t)
seedTaken(e, 100)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
if rec.Result().Header.Get(seoSlugHeader) != "" || len(e.articles.articles) != 100 {
t.Errorf("suggestion %q, %d articles", rec.Result().Header.Get(seoSlugHeader), len(e.articles.articles))
}
})
t.Run("A suggestion gives up when every candidate is taken", func(t *testing.T) {
e := newSeoEnv(t)
seedTaken(e, 100)
e.seedArticle(500, "Other", siteMainID, "other")
rec := e.patch(t, 500, `{"slug": "spring-release"}`)
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
if rec.Result().Header.Get(seoSlugHeader) != "" {
t.Errorf("suggestion %q", rec.Result().Header.Get(seoSlugHeader))
}
})
}
func TestSeoDataCleanerSiteLengthLimits(t *testing.T) {
locale35 := "en-abcdefgh-abcdefgh-abcdefgh-abcde"
locale36 := locale35 + "f"
host := func(length int) string { return "https://" + strings.Repeat("a", length-len("https://")) }
t.Run("A locale of 35 characters is the longest accepted", func(t *testing.T) {
accepted, acceptedErr := site.NormalizeLocale(&locale35)
rejected, rejectedErr := site.NormalizeLocale(&locale36)
if len(locale35) != 35 || len(locale36) != 36 || acceptedErr != nil || *accepted != locale35 || !errors.Is(rejectedErr, site.ErrInvalidLocale) || rejected != nil {
t.Errorf("accepted %v %v, rejected %v %v", accepted, acceptedErr, rejected, rejectedErr)
}
})
t.Run("A base URL of 255 characters is the longest accepted", func(t *testing.T) {
accepted, acceptedErr := site.NormalizeBaseURL(host(255))
rejected, rejectedErr := site.NormalizeBaseURL(host(256))
if acceptedErr != nil || accepted != host(255) || !errors.Is(rejectedErr, site.ErrInvalidSiteURL) || rejected != "" {
t.Errorf("accepted %d %v, rejected %q %v", len(accepted), acceptedErr, rejected, rejectedErr)
}
})
t.Run("Over-length input is a 400 over HTTP", func(t *testing.T) {
e := newSeoEnv(t)
locale := e.owner(t, http.MethodPatch, "/v1/site/2", `{"locale": "`+locale36+`"}`)
baseURL := e.owner(t, http.MethodPatch, "/v1/site/2", `{"base_url": "`+host(256)+`"}`)
created := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Long", "key": "long", "base_url": "`+host(256)+`"}`)
if locale.Code != http.StatusBadRequest || baseURL.Code != http.StatusBadRequest || created.Code != http.StatusBadRequest {
t.Errorf("locale %d (%s), base_url %d (%s), create %d (%s)", locale.Code, locale.Body.String(), baseURL.Code, baseURL.Body.String(), created.Code, created.Body.String())
}
})
}
type seoFailingCompensationSites struct{ *mockSiteRepo }
func (r seoFailingCompensationSites) AssignImage([]uint32, uint32) error {
return errors.New("sites compensation down")
}
type seoFailingCompensationAuthors struct{ *mockAuthorRepo }
func (r seoFailingCompensationAuthors) AssignAvatar([]uint32, uint32) error {
return errors.New("authors compensation down")
}
type seoFailingCompensationArticles struct{ *mockArticleRepo }
func (r seoFailingCompensationArticles) RestoreImage(article.ImageReferences, uint32) error {
return errors.New("articles compensation down")
}
type seoFlakyBodyArticles struct {
*mockArticleRepo
calls int
}
func (r *seoFlakyBodyArticles) UpdateBody(id uint32, body string) (article.Article, error) {
r.calls++
if r.calls > 1 {
return article.Article{}, errors.New("body write " + strconv.Itoa(r.calls) + " down")
}
return r.mockArticleRepo.UpdateBody(id, body)
}
func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buffer bytes.Buffer
log.SetOutput(&buffer)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
return &buffer
}
func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
t.Run("A failed delete logs every failed compensation", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.articles.fail.failNextWith("ClearImage", errors.New("clear down"))
buffer := captureLog(t)
remove := useCases.NewDeleteMediaUseCase(e.images, newMockImageStorage(), newMockVariantFileRepo(), e.audit.recorder, seoFailingCompensationSites{e.sites}, seoFailingCompensationAuthors{e.authors}, e.articles)
err := remove.Delete(t.Context(), 5)
logged := buffer.String()
if err == nil || !strings.Contains(logged, "restore site images: sites compensation down") || !strings.Contains(logged, "restore author avatars: authors compensation down") {
t.Errorf("err %v log %q", err, logged)
}
})
t.Run("A failed remove also logs the article restore", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.images.fail.failNextWith("Delete", errors.New("delete down"))
buffer := captureLog(t)
remove := useCases.NewDeleteMediaUseCase(e.images, newMockImageStorage(), newMockVariantFileRepo(), e.audit.recorder, seoFailingCompensationSites{e.sites}, seoFailingCompensationAuthors{e.authors}, seoFailingCompensationArticles{e.articles})
err := remove.Delete(t.Context(), 5)
logged := buffer.String()
if err == nil || !strings.Contains(logged, "restore site images: sites compensation down") || !strings.Contains(logged, "restore author avatars: authors compensation down") || !strings.Contains(logged, "restore article images: articles compensation down") {
t.Errorf("err %v log %q", err, logged)
}
})
t.Run("A failed merge logs the body it could not restore", func(t *testing.T) {
e := newSeoEnv(t)
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "One", siteMainID, "one")
e.seedArticle(9, "Two", siteMainID, "two")
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) })
buffer := captureLog(t)
flaky := &seoFlakyBodyArticles{mockArticleRepo: e.articles}
merge := useCases.NewMergeMediaUseCase(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
_, err := merge.Merge(t.Context(), 5, []uint32{6})
if err == nil || !strings.Contains(buffer.String(), "restore article body 8: body write 3 down") {
t.Errorf("err %v log %q", err, buffer.String())
}
})
}
type seoCaseInsensitiveBodyArticles struct{ *mockArticleRepo }
func (r seoCaseInsensitiveBodyArticles) FindByBodyContaining(filename string) ([]article.Article, error) {
found := []article.Article{}
for _, id := range r.sortedIds() {
stored := r.articles[id]
if strings.Contains(strings.ToLower(stored.Body), strings.ToLower(filename)) {
found = append(found, stored)
}
}
return found, nil
}
func TestSeoDataCleanerCaseInsensitiveBodyMatches(t *testing.T) {
seed := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Exact", siteMainID, "exact")
e.seedArticle(9, "Shouting", siteMainID, "shouting")
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.updateArticle(9, func(a *article.Article) { a.Body = strings.ToUpper(e.bodyUsing(6)) })
}
t.Run("Merge only counts the articles it rewrote", func(t *testing.T) {
e := newSeoEnv(t)
seed(e)
before := e.stored(9)
merge := useCases.NewMergeMediaUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
result, err := merge.Merge(t.Context(), 5, []uint32{6})
if err != nil || result.ArticlesUpdated != 1 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{8}) || !reflect.DeepEqual(e.stored(9), before) {
t.Errorf("result %+v, %v", result, err)
}
})
t.Run("Usage only lists real body references", func(t *testing.T) {
e := newSeoEnv(t)
seed(e)
usage := useCases.NewGetMediaUsageUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, e.sites, e.authors)
items, err := usage.Usage(6)
if err != nil || len(items) != 1 || items[0].ID != 8 {
t.Errorf("items %+v, %v", items, err)
}
})
}
+479
View File
@@ -0,0 +1,479 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"reflect"
"strconv"
"testing"
)
func (e *seoEnv) bodyUsing(imageID uint32) string {
return `<img src="` + producersBannerPath + strconv.Itoa(int(imageID)) + `.png">`
}
func (e *seoEnv) updateArticle(id uint32, change func(a *article.Article)) {
stored := e.articles.articles[id]
change(&stored)
e.articles.articles[id] = stored
}
func (e *seoEnv) usedIn(t *testing.T, imageID uint32) int {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/?size=100", "")
var listed struct {
Items []struct {
ID uint32 `json:"id"`
UsedIn int `json:"used_in"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
for _, item := range listed.Items {
if item.ID == imageID {
return item.UsedIn
}
}
t.Fatalf("image %d is not listed", imageID)
return 0
}
func (e *seoEnv) unusedListing(t *testing.T) []uint32 {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/?filter=unused&size=100", "")
var listed struct {
Items []struct {
ID uint32 `json:"id"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
ids := []uint32{}
for _, item := range listed.Items {
ids = append(ids, item.ID)
}
return ids
}
func (e *seoEnv) usage(t *testing.T, imageID uint32) []map[string]any {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/"+strconv.Itoa(int(imageID))+"/usage", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return items
}
func TestSeoDataMediaUsage(t *testing.T) {
t.Run("A cover or social image counts as used", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
items := e.usage(t, 5)
if len(items) != 1 || items[0]["id"] != float64(seoSpringID) || e.usedIn(t, 5) != 1 || len(e.unusedListing(t)) != 1 {
t.Errorf("usage %v, used_in %d, unused %v", items, e.usedIn(t, 5), e.unusedListing(t))
}
})
t.Run("Each reference kind counts", func(t *testing.T) {
cases := []struct {
name string
prep func(e *seoEnv)
listed bool
}{
{"the cover of an article", func(e *seoEnv) {
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
}, false},
{"the social image of an article", func(e *seoEnv) {
e.updateArticle(seoSpringID, func(a *article.Article) { a.SocialImageID = idPointer(5) })
}, false},
{"the default social image of a site", func(e *seoEnv) {
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
}, false},
{"the avatar of an author", func(e *seoEnv) {
e.authors.seed(2, "Camille", "camille")
a := e.authors.authors[2]
a.AvatarID = idPointer(5)
e.authors.authors[2] = a
}, false},
{"a file name in an article body", func(e *seoEnv) { e.updateArticle(seoSpringID, func(a *article.Article) { a.Body = e.bodyUsing(5) }) }, false},
{"nothing", func(e *seoEnv) {}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
c.prep(e)
unused := e.unusedListing(t)
listed := false
for _, id := range unused {
if id == 5 {
listed = true
}
}
if listed != c.listed {
t.Errorf("unused = %v", unused)
}
})
}
})
t.Run("An article that uses the image twice counts once", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) {
a.Body = e.bodyUsing(5)
a.CoverImageID = idPointer(5)
a.SocialImageID = idPointer(5)
})
if used := e.usedIn(t, 5); used != 1 {
t.Errorf("used_in = %d", used)
}
})
t.Run("The media list sums every kind", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "Cover", siteMainID, "cover")
e.seedArticle(2, "Social", siteMainID, "social")
e.seedArticle(3, "Body", siteMainID, "body")
e.updateArticle(1, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.updateArticle(2, func(a *article.Article) { a.SocialImageID = idPointer(5) })
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5) })
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
if used := e.usedIn(t, 5); used != 5 {
t.Errorf("used_in = %d", used)
}
})
t.Run("Usage lists articles, authors and sites", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(3, "Body article", siteMainID, "body-article")
e.seedArticle(8, "Cover article", siteMainID, "cover-article")
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5); a.IsOnline = true })
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
main := e.sites.sites[siteMainID]
main.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteMainID] = main
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5/usage", "")
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"]},
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"]},
{"type": "author", "id": 2, "title": "Camille", "fields": ["avatar"]},
{"type": "site", "id": 1, "title": "Main", "fields": ["default_social_image"]}]`
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
expectJSONEqual(t, rec.Body.String(), want)
})
t.Run("The change is additive for existing consumers", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(3, "Body article", siteMainID, "body-article")
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5); a.IsOnline = true })
items := e.usage(t, 5)
keys := []string{}
for key := range items[0] {
keys = append(keys, key)
}
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 5 {
t.Errorf("item = %v", items)
}
})
t.Run("An article using an image in several places is one item with all fields", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) {
a.Body = e.bodyUsing(5)
a.CoverImageID = idPointer(5)
a.SocialImageID = idPointer(5)
})
items := e.usage(t, 5)
if len(items) != 1 || !reflect.DeepEqual(items[0]["fields"], []any{"body", "cover", "social"}) {
t.Errorf("items = %v", items)
}
})
t.Run("An unused image has an empty usage array", func(t *testing.T) {
e := newSeoEnv(t)
e.seedBanner(9, "unused.png", "hash-unused")
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/9/usage", "")
expectStatusAndText(t, rec, http.StatusOK, "[]")
})
t.Run("An unknown image is not found", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/999/usage", "")
expectStatusAndText(t, rec, http.StatusNotFound, "Image not found")
})
t.Run("Offline articles are listed with published false", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
items := e.usage(t, 5)
if len(items) != 1 || items[0]["published"] != false {
t.Errorf("items = %v", items)
}
})
}
func TestSeoDataMediaDelete(t *testing.T) {
seedReferences := func(e *seoEnv) {
e.seedArticle(8, "Cover", siteMainID, "cover")
e.seedArticle(9, "Social", siteMainID, "social")
e.updateArticle(8, func(a *article.Article) {
a.CoverImageID = idPointer(5)
a.CoverImageAlt = "A field"
a.Body = "Body 8"
})
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(5); a.Body = "Body 9" })
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
}
t.Run("Deleting an image in use clears the references and is never refused", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if e.stored(8).CoverImageID != nil || e.stored(9).SocialImageID != nil || e.stored(8).CoverImageAlt != "A field" || e.stored(8).Body != "Body 8" || e.stored(9).Body != "Body 9" {
t.Errorf("articles = %+v %+v", e.stored(8), e.stored(9))
}
if e.authors.authors[2].AvatarID != nil || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
t.Errorf("avatar or site reference left")
}
})
t.Run("A failed delete leaves every reference in place", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
e.images.failDeleteFor = map[uint32]error{5: errors.New("storage down")}
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
cover, social := e.stored(8).CoverImageID, e.stored(9).SocialImageID
if cover == nil || *cover != 5 || social == nil || *social != 5 {
t.Errorf("article references = %v %v", cover, social)
}
if e.authors.authors[2].AvatarID == nil || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("avatar or site reference lost")
}
})
t.Run("A failure clearing the article references restores the other ones", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
e.articles.fail.failNext("ClearImage")
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError || e.authors.authors[2].AvatarID == nil || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("status %d", rec.Code)
}
})
}
func TestSeoDataMediaMerge(t *testing.T) {
seedDuplicates := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Cover", siteMainID, "cover")
e.seedArticle(9, "Social", siteMainID, "social")
}
mergeBody := `{"keep_id": 5, "merge_ids": [6]}`
t.Run("Merging copies repoints article cover and social images", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["articles_updated"] != float64(2) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if *e.stored(8).CoverImageID != 5 || *e.stored(9).SocialImageID != 5 {
t.Errorf("references = %v %v", e.stored(8).CoverImageID, e.stored(9).SocialImageID)
}
})
t.Run("The merge result lists the updated article ids", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
result, err := merge.Merge(testCtx, 5, []uint32{6})
if err != nil || result.ArticlesUpdated != 2 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{8, 9}) {
t.Errorf("result %+v, %v", result, err)
}
})
t.Run("An article rewritten in body and cover counts once", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6); a.Body = e.bodyUsing(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
if decodeSeoObject(t, rec)["articles_updated"] != float64(1) || *e.stored(8).CoverImageID != 5 || e.stored(8).Body != e.bodyUsing(5) {
t.Errorf("status %d body %s stored %+v", rec.Code, rec.Body.String(), e.stored(8))
}
})
t.Run("Authors and sites keep being repointed", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(6)
e.authors.authors[2] = author
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(6)
e.sites.sites[siteShopID] = shop
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
decoded := decodeSeoObject(t, rec)
if decoded["authors_updated"] != float64(1) || decoded["sites_updated"] != float64(1) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("A failed repoint leaves the bodies as they were", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.articles.fail.failNext("RepointImage")
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
if rec.Code != http.StatusInternalServerError || e.stored(8).Body != e.bodyUsing(6) {
t.Errorf("status %d body %q", rec.Code, e.stored(8).Body)
}
})
}
func TestSeoDataMediaPurge(t *testing.T) {
seedGroup := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Cover", siteMainID, "cover")
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
}
t.Run("Purge repoints articles the same way", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.updateArticle(8, func(a *article.Article) { a.SocialImageID = idPointer(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["articles_updated"] != float64(1) || *e.stored(8).SocialImageID != 5 || *e.stored(8).CoverImageID != 5 {
t.Errorf("status %d body %s stored %+v", rec.Code, rec.Body.String(), e.stored(8))
}
})
t.Run("A failed purge group leaves covers pointing at existing images", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
e.images.failDeleteFor = map[uint32]error{6: errors.New("storage down"), 5: errors.New("storage down")}
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["failed_groups"] != float64(1) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
for _, stored := range e.articles.articles {
for _, reference := range []*uint32{stored.CoverImageID, stored.SocialImageID} {
if reference == nil {
continue
}
if _, exists := e.images.images[*reference]; !exists {
t.Errorf("article %d points at the missing image %d", stored.ID, *reference)
}
}
}
})
t.Run("Cover and social usage counts in the purge preview", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
rec := e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/purge-preview", "")
var preview struct {
Groups int `json:"groups"`
Articles int `json:"articles"`
List []struct {
KeepID uint32 `json:"keep_id"`
UsedIn int `json:"used_in"`
} `json:"list"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &preview); err != nil || rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if preview.Groups != 1 || preview.Articles != 0 || len(preview.List) != 1 || preview.List[0].KeepID != 6 || preview.List[0].UsedIn != 1 {
t.Errorf("preview = %+v", preview)
}
})
}
+435
View File
@@ -0,0 +1,435 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"reflect"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
type seoPreSliceArticle struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (seoPreSliceArticle) TableName() string { return "articles" }
type seoPreSliceSite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:100;not null"`
Key string `gorm:"column:site_key;size:32;not null;uniqueIndex"`
BaseURL string `gorm:"size:255;not null"`
DefaultSocialImageID *uint32
DefaultRobots string `gorm:"size:16;not null"`
Locale *string `gorm:"size:35"`
IsDefault bool `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (seoPreSliceSite) TableName() string { return "sites" }
type seoLegacyRow struct {
id uint32
siteID uint32
title string
slug string
}
func openPreSliceDatabase(t *testing.T, rows ...seoLegacyRow) *gorm.DB {
t.Helper()
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&seoPreSliceArticle{}); err != nil {
t.Fatalf("pre-slice migrate: %v", err)
}
for _, row := range rows {
if err := db.Create(&seoPreSliceArticle{ID: row.id, Title: row.title, SiteID: row.siteID, Body: "Body"}).Error; err != nil {
t.Fatalf("seed %d: %v", row.id, err)
}
}
return db
}
func migrateSeo(t *testing.T, db *gorm.DB) {
t.Helper()
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
}
func storedSlugs(t *testing.T, db *gorm.DB) map[uint32]string {
t.Helper()
var rows []entity.Article
if err := db.Unscoped().Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("read articles: %v", err)
}
slugs := map[uint32]string{}
for _, row := range rows {
slugs[row.ID] = row.Slug
}
return slugs
}
func TestSeoDataMigrationBackfill(t *testing.T) {
t.Run("Every existing article gets a slug from its title", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "Spring release"}, seoLegacyRow{id: 2, siteID: 1, title: "Autumn notes"})
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "spring-release", 2: "autumn-notes"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("Conflicts get suffixes in id order within a site", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}, seoLegacyRow{id: 3, siteID: 1, title: "News"})
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "news", 2: "news-2", 3: "news-3"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("A suffix skips slugs that exist", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News 2"}, seoLegacyRow{id: 3, siteID: 1, title: "News"})
migrateSeo(t, db)
got := storedSlugs(t, db)
if got[1] != "news" || got[2] != "news-2" || got[3] != "news-3" {
t.Errorf("slugs = %v", got)
}
})
t.Run("Slugs are unique per site, not globally", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 2, title: "News"})
migrateSeo(t, db)
if got := storedSlugs(t, db); got[1] != "news" || got[2] != "news" {
t.Errorf("slugs = %v", got)
}
})
t.Run("A title with no letter or digit becomes article, suffixed on conflict", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 5, siteID: 1, title: "!!!"}, seoLegacyRow{id: 6, siteID: 1, title: "???"}, seoLegacyRow{id: 7, siteID: 1, title: "..."})
migrateSeo(t, db)
if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{5: "article", 6: "article-2", 7: "article-3"}) {
t.Errorf("slugs = %v", got)
}
})
t.Run("Long titles stay within 100 characters", func(t *testing.T) {
title := strings.Repeat("a", 150)
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: title}, seoLegacyRow{id: 2, siteID: 1, title: title}, seoLegacyRow{id: 3, siteID: 1, title: title})
migrateSeo(t, db)
seen := map[string]bool{}
for id, slug := range storedSlugs(t, db) {
if len(slug) > 100 || !article.IsValidSlug(slug) || seen[slug] {
t.Errorf("article %d slug %q (%d)", id, slug, len(slug))
}
seen[slug] = true
}
})
t.Run("The migration is idempotent", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"})
migrateSeo(t, db)
first := storedSlugs(t, db)
migrateSeo(t, db)
if second := storedSlugs(t, db); !reflect.DeepEqual(first, second) {
t.Errorf("first %v, second %v", first, second)
}
})
t.Run("An edited slug survives a restart", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"})
migrateSeo(t, db)
if err := db.Model(&entity.Article{}).Where("id = ?", 1).UpdateColumn("slug", "custom").Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
if got := storedSlugs(t, db)[1]; got != "custom" {
t.Errorf("slug = %q", got)
}
})
t.Run("A database with a partially backfilled table completes", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}, seoLegacyRow{id: 3, siteID: 1, title: "Other"})
migrateSeo(t, db)
if err := db.Exec("DROP INDEX idx_articles_site_slug").Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&entity.Article{}).Where("id = ?", 2).UpdateColumn("slug", "").Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&entity.Article{}).Where("id = ?", 1).UpdateColumn("slug", "kept").Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
got := storedSlugs(t, db)
if got[1] != "kept" || got[3] != "other" || got[2] != "news" {
t.Errorf("slugs = %v", got)
}
})
t.Run("Soft-deleted legacy articles get a slug that frees the title", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"})
if err := db.Delete(&seoPreSliceArticle{}, 1).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
got := storedSlugs(t, db)
if got[2] != "news" || got[1] != article.DeletedSlug+"1" {
t.Errorf("slugs = %v", got)
}
})
t.Run("New site columns get defaults", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&seoPreSliceSite{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&seoPreSliceSite{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index", IsDefault: true}).Error; err != nil {
t.Fatal(err)
}
migrateSeo(t, db)
migrateSeo(t, db)
sites := storedSites(t, db)
if len(sites) != 1 || sites[0].SiteName != "" || sites[0].TitleTemplate != "%title% | %site%" || sites[0].DefaultDescription != "" {
t.Errorf("sites = %+v", sites)
}
})
t.Run("Existing articles get neutral SEO defaults", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"})
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}
if err != nil || !reflect.DeepEqual(found, want) {
t.Errorf("found %+v, %v", found, err)
}
})
t.Run("The migration creates the per-site unique index", func(t *testing.T) {
db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"})
migrateSeo(t, db)
if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
t.Fatal("the index is missing")
}
duplicate := entity.Article{Title: "Again", SiteID: 1, Slug: "news"}
if err := db.Create(&duplicate).Error; err == nil {
t.Error("a duplicate (site_id, slug) was accepted")
}
})
}
func TestSeoDataArticleRepositoryContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
cases := []struct {
name string
run func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32)
}{
{"every new column round-trips with null image ids and the dismissed list", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
images := newContractRepositories(db)
cover := mustCreateImage(t, images, "cover.png", "hash-1")
social := mustCreateImage(t, images, "social.png", "hash-2")
created, err := articles.Create(article.Article{Title: "Spring", Body: "Body", SiteID: siteID, Slug: "spring", Excerpt: "Teaser", SEOTitle: "SEO", SEODescription: "Description", CoverImageID: &cover.ID, CoverImageAlt: "Alt", SocialTitle: "Social", SocialDescription: "Social description", SocialImageID: &social.ID, CanonicalURL: "https://example.com/a", Noindex: true, SEODismissed: []string{"short", "cover_alt"}})
plain, plainErr := articles.Create(article.Article{Title: "Plain", Body: "Body", SiteID: siteID, Slug: "plain"})
read, readErr := articles.Get(created.ID)
readPlain, _ := articles.Get(plain.ID)
if err != nil || readErr != nil || plainErr != nil {
t.Fatalf("errors: %v %v %v", err, readErr, plainErr)
}
if read.Slug != "spring" || read.Excerpt != "Teaser" || read.SEOTitle != "SEO" || read.SEODescription != "Description" || read.CoverImageID == nil || *read.CoverImageID != cover.ID || read.CoverImageAlt != "Alt" ||
read.SocialTitle != "Social" || read.SocialDescription != "Social description" || read.SocialImageID == nil || *read.SocialImageID != social.ID || read.CanonicalURL != "https://example.com/a" || !read.Noindex ||
!reflect.DeepEqual(read.SEODismissed, []string{"short", "cover_alt"}) {
t.Errorf("read = %+v", read)
}
if readPlain.CoverImageID != nil || readPlain.SocialImageID != nil || readPlain.Noindex || readPlain.SEODismissed == nil || len(readPlain.SEODismissed) != 0 {
t.Errorf("plain = %+v", readPlain)
}
}},
{"a second article with the same site and slug is refused, another site is allowed", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
sites := persistence.NewSiteRepository(db)
other := mustCreateSite(t, sites, "Shop", "shop", false)
if _, err := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"}); err != nil {
t.Fatal(err)
}
_, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"})
_, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, Slug: "spring"})
rawDuplicate := db.Create(&entity.Article{Title: "Four", SiteID: siteID, Slug: "spring"}).Error
if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil {
t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate)
}
}},
{"update applies the SEO fields, clears the image ids and refuses a taken slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
images := newContractRepositories(db)
cover := mustCreateImage(t, images, "cover.png", "hash-1")
first, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "one"})
second, _ := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "two"})
text, flag, codes := "Teaser", true, []string{"short"}
updated, err := articles.Update(first.ID, article.Update{Excerpt: &text, Noindex: &flag, SEODismissed: &codes, CoverImageID: article.Nullable[uint32]{Set: true, Value: &cover.ID}})
cleared, clearErr := articles.Update(first.ID, article.Update{CoverImageID: article.Nullable[uint32]{Set: true}})
taken := "two"
_, conflict := articles.Update(first.ID, article.Update{Slug: &taken})
own := "two"
_, keeping := articles.Update(second.ID, article.Update{Slug: &own})
if err != nil || updated.Excerpt != "Teaser" || !updated.Noindex || !reflect.DeepEqual(updated.SEODismissed, codes) || updated.CoverImageID == nil {
t.Fatalf("updated = %+v, %v", updated, err)
}
if clearErr != nil || cleared.CoverImageID != nil || cleared.Excerpt != "Teaser" {
t.Errorf("cleared = %+v, %v", cleared, clearErr)
}
if !errors.Is(conflict, article.ErrSlugTaken) || keeping != nil {
t.Errorf("conflict %v, keeping %v", conflict, keeping)
}
}},
{"moving an article re-checks its slug on the target site", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
other := mustCreateSite(t, persistence.NewSiteRepository(db), "Shop", "shop", false)
moving, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
if _, err := articles.Create(article.Article{Title: "Two", SiteID: other.ID, Slug: "spring"}); err != nil {
t.Fatal(err)
}
_, err := articles.Update(moving.ID, article.Update{SiteID: &other.ID})
stored, _ := articles.Get(moving.ID)
if !errors.Is(err, article.ErrSlugTaken) || stored.SiteID != siteID {
t.Errorf("err %v stored %+v", err, stored)
}
}},
{"slug taken ignores the article itself and a deleted article frees its slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
own, ownErr := articles.SlugTaken(siteID, "spring", created.ID)
other, otherErr := articles.SlugTaken(siteID, "spring", 0)
deleteErr := articles.Delete(created.ID)
afterDelete, afterErr := articles.SlugTaken(siteID, "spring", 0)
_, recreated := articles.Create(article.Article{Title: "Again", SiteID: siteID, Slug: "spring"})
if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil {
t.Errorf("own %v other %v delete %v after %v recreated %v", own, other, deleteErr, afterDelete, recreated)
}
if err := articles.Delete(created.ID); !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("second delete: %v", err)
}
}},
{"image references are found, cleared, restored and repointed", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
images := newContractRepositories(db)
kept := mustCreateImage(t, images, "kept.png", "hash-a")
copied := mustCreateImage(t, images, "copy.png", "hash-a")
covered, _ := articles.Create(article.Article{Title: "Covered", SiteID: siteID, Slug: "covered", CoverImageID: &copied.ID})
shared, _ := articles.Create(article.Article{Title: "Shared", SiteID: siteID, Slug: "shared", CoverImageID: &copied.ID, SocialImageID: &copied.ID})
articles.Create(article.Article{Title: "Unrelated", SiteID: siteID, Slug: "unrelated"})
found, findErr := articles.FindByImage(copied.ID)
repointed, repointErr := articles.RepointImage([]uint32{copied.ID}, kept.ID)
references, clearErr := articles.ClearImage(kept.ID)
afterClear, _ := articles.Get(shared.ID)
restoreErr := articles.RestoreImage(references, kept.ID)
afterRestore, _ := articles.Get(shared.ID)
if findErr != nil || len(found) != 2 {
t.Fatalf("found %+v, %v", found, findErr)
}
if repointErr != nil || !reflect.DeepEqual(repointed, []uint32{covered.ID, shared.ID}) {
t.Fatalf("repointed %v, %v", repointed, repointErr)
}
if clearErr != nil || !reflect.DeepEqual(references.Cover, []uint32{covered.ID, shared.ID}) || !reflect.DeepEqual(references.Social, []uint32{shared.ID}) || afterClear.CoverImageID != nil || afterClear.SocialImageID != nil {
t.Errorf("references %+v, %v, after %+v", references, clearErr, afterClear)
}
if restoreErr != nil || afterRestore.CoverImageID == nil || *afterRestore.CoverImageID != kept.ID || afterRestore.SocialImageID == nil || *afterRestore.SocialImageID != kept.ID {
t.Errorf("restore %v, after %+v", restoreErr, afterRestore)
}
}},
{"the site repository stores and updates the SEO columns", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
sites := persistence.NewSiteRepository(db)
created, err := sites.Create(site.Site{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index", SiteName: "Renew Shop", TitleTemplate: "%title% - %site%", DefaultDescription: "A shop."})
created.SiteName = "Renew"
updated, updateErr := sites.Update(created)
seeded, _ := sites.GetDefault()
if err != nil || updateErr != nil || updated.SiteName != "Renew" || updated.TitleTemplate != "%title% - %site%" || updated.DefaultDescription != "A shop." {
t.Errorf("updated %+v, %v %v", updated, err, updateErr)
}
if seeded.TitleTemplate != "%title% | %site%" {
t.Errorf("seeded template = %q", seeded.TitleTemplate)
}
}},
{"the author repository finds the authors using an avatar", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
authors := persistence.NewAuthorRepository(db)
image := mustCreateImage(t, newContractRepositories(db), "avatar.png", "hash-avatar")
withAvatar, _ := authors.Create(authorWithAvatar("Camille", "camille", &image.ID))
authors.Create(authorWithAvatar("Other", "other", nil))
found, err := authors.FindByAvatar(image.ID)
if err != nil || len(found) != 1 || found[0].ID != withAvatar.ID {
t.Errorf("found %+v, %v", found, err)
}
}},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := backend.open(t)
defaultSite, err := persistence.NewSiteRepository(db).GetDefault()
if err != nil {
t.Fatal(err)
}
c.run(t, db, persistence.NewArticleRepository(db), defaultSite.ID)
})
}
})
}
}
func authorWithAvatar(name string, slug string, avatarID *uint32) author.Author {
return author.Author{Name: name, Slug: slug, AvatarID: avatarID, SocialLinks: []author.SocialLink{}}
}
+334
View File
@@ -0,0 +1,334 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"net/http"
"reflect"
"strings"
"testing"
)
func TestSeoDataSiteFields(t *testing.T) {
const validator = "validator"
validSite := func(extra string) string {
return `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"` + extra + `}`
}
t.Run("The owner sets the site SEO fields", func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.sites[siteShopID]
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got := decodeSiteBody(t, rec)
want := before
want.SiteName, want.TitleTemplate, want.DefaultDescription = "Renew Shop", "%title% - %site%", "A shop about renewal."
if !reflect.DeepEqual(got, want) || e.sites.sites[siteShopID].SiteName != "Renew Shop" {
t.Errorf("got %+v, expected %+v", got, want)
}
})
t.Run("A site created without the new fields has the defaults", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`)
created := decodeSiteBody(t, rec)
if rec.Code != http.StatusCreated || created.SiteName != "" || created.TitleTemplate != "%title% | %site%" || created.DefaultDescription != "" {
t.Errorf("status %d site %+v", rec.Code, created)
}
})
t.Run("A site is created with the new fields", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(`, "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."`))
created := decodeSiteBody(t, rec)
stored := e.sites.sites[created.ID]
if rec.Code != http.StatusCreated || created.SiteName != "Renew Shop" || created.TitleTemplate != "%title% - %site%" || created.DefaultDescription != "A shop about renewal." {
t.Fatalf("status %d site %+v", rec.Code, created)
}
if stored.SiteName != "Renew Shop" || stored.TitleTemplate != "%title% - %site%" || stored.DefaultDescription != "A shop about renewal." {
t.Errorf("stored %+v", stored)
}
})
t.Run("Creation validates the new fields like editing", func(t *testing.T) {
cases := []struct {
name string
extra string
status int
text string
}{
{"template with title", `, "title_template": "%title%"`, 201, ""},
{"template without title", `, "title_template": "just text"`, 400, "invalid_title_template"},
{"template with site only", `, "title_template": "%site%"`, 400, "invalid_title_template"},
{"empty template", `, "title_template": ""`, 400, "invalid_title_template"},
{"template 201", `, "title_template": "%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
{"description 320", `, "default_description": "` + strings.Repeat("a", 320) + `"`, 201, ""},
{"description 321", `, "default_description": "` + strings.Repeat("a", 321) + `"`, 400, validator},
{"site name 100", `, "site_name": "` + strings.Repeat("a", 100) + `"`, 201, ""},
{"site name 101", `, "site_name": "` + strings.Repeat("a", 101) + `"`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
sitesBefore := len(e.sites.sites)
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(c.extra))
switch {
case c.status == http.StatusCreated:
if rec.Code != c.status {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
case c.text == validator:
expectStatusAndValidatorText(t, rec, c.status)
default:
expectStatusAndText(t, rec, c.status, c.text)
}
if c.status != http.StatusCreated && len(e.sites.sites) != sitesBefore {
t.Errorf("a site was added")
}
})
}
})
t.Run("The list carries the new fields", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "")
var items []map[string]any
for _, item := range decodeSiteList(t, rec) {
items = append(items, map[string]any{"site_name": item.SiteName, "title_template": item.TitleTemplate})
}
if rec.Code != http.StatusOK || len(items) != 2 || !strings.Contains(rec.Body.String(), `"site_name":""`) || !strings.Contains(rec.Body.String(), `"title_template":"%title% | %site%"`) || !strings.Contains(rec.Body.String(), `"default_description":""`) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newSeoEnv(t)
shop := e.sites.sites[siteShopID]
shop.TitleTemplate, shop.DefaultDescription = "%title% :: %site%", "Kept."
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew"}`)
want := shop
want.SiteName = "Renew"
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
t.Errorf("status %d stored %+v", rec.Code, e.sites.sites[siteShopID])
}
})
t.Run("The template must contain %title%", func(t *testing.T) {
cases := []struct {
name string
template string
status int
text string
}{
{"title only", `"%title%"`, 200, ""},
{"title and site", `"%title% | %site%"`, 200, ""},
{"site first", `"%site% - %title%"`, 200, ""},
{"prefix", `"News: %title%"`, 200, ""},
{"site only", `"%site%"`, 400, "invalid_title_template"},
{"plain text", `"just text"`, 400, "invalid_title_template"},
{"upper case placeholder", `"%TITLE%"`, 400, "invalid_title_template"},
{"empty", `""`, 400, "invalid_title_template"},
{"201 characters", `"%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"title_template": `+c.template+`}`)
switch {
case c.status == http.StatusOK:
if rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
case c.text == validator:
expectStatusAndValidatorText(t, rec, c.status)
default:
expectStatusAndText(t, rec, c.status, c.text)
}
})
}
})
t.Run("Other site SEO fields are validated", func(t *testing.T) {
cases := []struct {
name string
body string
status int
}{
{"description 321", `{"default_description": "` + strings.Repeat("a", 321) + `"}`, 400},
{"description 320", `{"default_description": "` + strings.Repeat("a", 320) + `"}`, 200},
{"description empty", `{"default_description": ""}`, 200},
{"site name 101", `{"site_name": "` + strings.Repeat("a", 101) + `"}`, 400},
{"site name empty", `{"site_name": ""}`, 200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != c.status {
t.Errorf("status %d: %.120s", rec.Code, rec.Body.String())
}
if c.status == http.StatusBadRequest {
expectStatusAndValidatorText(t, rec, c.status)
}
})
}
})
t.Run("A refused site update changes no other field", func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.sites[siteShopID]
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "title_template": "nope"}`)
expectStatusAndText(t, rec, http.StatusBadRequest, "invalid_title_template")
if e.sites.sites[siteShopID].Name != before.Name {
t.Errorf("the name changed to %q", e.sites.sites[siteShopID].Name)
}
})
t.Run("The existing site fields keep their names", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5, "default_robots": "noindex"}`)
got := decodeSiteBody(t, rec)
if rec.Code != http.StatusOK || got.DefaultSocialImageID == nil || *got.DefaultSocialImageID != 5 || got.DefaultRobots != "noindex" {
t.Errorf("status %d site %+v", rec.Code, got)
}
})
t.Run("Other roles and keys are refused", func(t *testing.T) {
const ownerText, sessionText = "Only the owner can do this.", "This action needs a real session."
cases := []struct {
name string
method string
path string
send func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult
text string
}{
{"PATCH with a verified admin", http.MethodPatch, "/v1/site/2", seoAsUser(apiKeyDevId), ownerText},
{"PATCH with a verified account with another role", http.MethodPatch, "/v1/site/2", seoAsUser(seoEditorID), ownerText},
{"PATCH with a valid API key", http.MethodPatch, "/v1/site/2", seoAsKeyOf(apiKeyDevId), sessionText},
{"POST with a verified admin", http.MethodPost, "/v1/site/", seoAsUser(apiKeyDevId), ownerText},
{"POST with a valid API key", http.MethodPost, "/v1/site/", seoAsKeyOf(apiKeyDevId), sessionText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.snapshot()
body := `{"site_name": "Hijack"}`
if c.method == http.MethodPost {
body = validSite(`, "site_name": "Hijack"`)
}
rec := c.send(e, t, c.method, c.path, body)
if rec.code != http.StatusForbidden || rec.text != c.text || !reflect.DeepEqual(e.sites.snapshot(), before) {
t.Errorf("status %d body %q", rec.code, rec.text)
}
})
}
})
t.Run("No credentials is unauthorised", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, 0, http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The role is checked before the body", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", "{not json")
expectStatusAndText(t, rec, http.StatusForbidden, "Only the owner can do this.")
})
t.Run("A no-op site edit records nothing", func(t *testing.T) {
e := newSeoEnv(t)
shop := e.sites.sites[siteShopID]
shop.SiteName, shop.DefaultDescription = "Renew", "A shop."
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew", "title_template": "%title% | %site%", "default_description": "A shop."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A site SEO edit is recorded", func(t *testing.T) {
cases := []struct{ body, field, from, to string }{
{`{"site_name": "Renew"}`, "site_name", "", "Renew"},
{`{"title_template": "%title% - %site%"}`, "title_template", "%title% | %site%", "%title% - %site%"},
{`{"default_description": "A shop."}`, "default_description", "", "A shop."},
}
for _, c := range cases {
t.Run(c.field, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionSiteEdit, audit.CategorySites, audit.OutcomeSuccess)
want := `[{"field":"` + c.field + `","kind":"text","before":"` + c.from + `","after":"` + c.to + `"}]`
if got := changesJSON(t, event); got != want {
t.Errorf("changes = %s, expected %s", got, want)
}
})
}
})
t.Run("The site defaults are the documented ones", func(t *testing.T) {
if site.DefaultTitleTemplate != "%title% | %site%" {
t.Errorf("template = %q", site.DefaultTitleTemplate)
}
})
}
type seoResult struct {
code int
text string
}
func seoAsUser(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
rec := e.as(t, userID, method, path, body)
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
}
}
func seoAsKeyOf(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
raw, _ := e.issueKey(userID, "Deploy bot")
rec := e.asKey(t, raw, method, path, body)
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
}
}
+158
View File
@@ -0,0 +1,158 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
seoEditorID = 5
seoSpringID = 8
seoOtherID = 9
seoCoverImage = 5
seoSocialImage = 6
seoSlugHeader = "X-Slug-Suggestion"
seoSlugTaken = "slug_taken"
seoTakenReason = "That slug is already used by another article on this site."
seoSpringTitle = "Spring release"
seoSpringSlug = "spring-release"
)
type seoEnv struct {
*producersEnv
sites *mockSiteRepo
authors *mockAuthorRepo
}
func newSeoEnv(t *testing.T) *seoEnv {
t.Helper()
p := newProducersEnv(t)
p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor")}
p.users.nextID = seoEditorID
authors := newMockAuthorRepo(p.articles)
sites := newMockSiteRepo(p.articles)
sites.seed(siteMainID, "Main", "main", true)
sites.seed(siteShopID, "Shop", "shop", false)
for id, seeded := range sites.sites {
seeded.TitleTemplate = site.DefaultTitleTemplate
sites.sites[id] = seeded
}
recorder := p.audit.recorder
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
c.MergeMediaUseCase = mergeMedia
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors)
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(c, p.settings))
p.router = router
p.seedBanner(seoCoverImage, "cover.png", "hash-cover")
p.seedBanner(seoSocialImage, "social.png", "hash-social")
return &seoEnv{producersEnv: p, sites: sites, authors: authors}
}
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{}}
e.articles.articles[id] = seeded
if id > e.articles.nextID {
e.articles.nextID = id
}
return seeded
}
func (e *seoEnv) seedSpring() article.Article {
return e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, seoSpringSlug)
}
func (e *seoEnv) seedFullyOptimised(id uint32) {
stored := e.seedArticle(id, seoSpringTitle, siteMainID, "spring-full")
stored.Excerpt = "Short teaser."
stored.SEOTitle = "Spring release notes"
stored.SEODescription = "Everything new this spring."
stored.CoverImageID = idPointer(seoCoverImage)
stored.CoverImageAlt = "A field of tulips"
stored.SocialTitle = "Spring is here"
stored.SocialDescription = "Read the notes."
stored.SocialImageID = idPointer(seoSocialImage)
stored.CanonicalURL = "https://www.example.com/spring"
stored.Noindex = true
stored.SEODismissed = []string{"cover_alt", "short"}
e.articles.articles[id] = stored
}
func (e *seoEnv) patch(t *testing.T, id uint32, body string) *httptest.ResponseRecorder {
t.Helper()
return e.owner(t, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(id)), body)
}
func (e *seoEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.as(t, apiKeyOwnerId, method, path, body)
}
func (e *seoEnv) stored(id uint32) article.Article {
return e.articles.articles[id]
}
func decodeSeoObject(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return decoded
}
func expectStatusAndText(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, strings.TrimSpace(rec.Body.String()))
}
}
func expectStatusAndValidatorText(t *testing.T, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status || !strings.Contains(rec.Body.String(), "Field validation") {
t.Fatalf("expected %d with the validator text, got %d %q", status, rec.Code, rec.Body.String())
}
}
func repeated(character string, count int) string {
return strings.Repeat(character, count)
}
func jsonString(t *testing.T, value string) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
+1 -1
View File
@@ -103,7 +103,7 @@ func TestSitesCreating(t *testing.T) {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
created := decodeSiteBody(t, rec)
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA")}
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA"), TitleTemplate: site.DefaultTitleTemplate}
if created.ID == 0 || !reflect.DeepEqual(created, want) {
t.Errorf("created = %+v", created)
}
+1 -1
View File
@@ -116,7 +116,7 @@ func TestSiteUseCaseRules(t *testing.T) {
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
sites := newMockSiteRepo(repo)
sites.seed(1, "Main", "main", true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo())
missing := uint32(9)
_, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing})
+8 -4
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
@@ -121,7 +120,7 @@ func TestSitesMediaUsage(t *testing.T) {
}
})
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
t.Run("The usage lookup stays an array and later gains additive keys", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
e.useImageOnSite(siteShopID, 5)
@@ -129,8 +128,13 @@ func TestSitesMediaUsage(t *testing.T) {
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "site") {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil || rec.Code != http.StatusOK || len(items) != 2 {
t.Fatalf("status %d, body %s", rec.Code, rec.Body.String())
}
first := items[0]
if first["id"] != float64(1) || first["title"] != "Spring release" || first["published"] != true || first["type"] != "article" {
t.Errorf("the article item lost a key: %v", first)
}
})
}
+9 -1
View File
@@ -8,7 +8,9 @@ import (
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"gorm.io/gorm"
@@ -346,8 +348,14 @@ func mustCreateSite(t *testing.T, sites *persistence.SiteRepository, name string
return created
}
var persistedArticleCounter atomic.Uint32
func uniqueTestSlug() string {
return "article-" + strconv.Itoa(int(persistedArticleCounter.Add(1)))
}
func articleOn(siteID uint32) article.Article {
return article.Article{Title: "Article", Body: "Body", SiteID: siteID}
return article.Article{Title: "Article", Body: "Body", SiteID: siteID, Slug: uniqueTestSlug()}
}
func assertSingleDefault(t *testing.T, db *gorm.DB) {
+4 -2
View File
@@ -152,6 +152,7 @@ func (m *mockSiteRepo) Update(s site.Site) (site.Site, error) {
}
current.Name, current.BaseURL, current.DefaultRobots = s.Name, s.BaseURL, s.DefaultRobots
current.Locale, current.DefaultSocialImageID = s.Locale, s.DefaultSocialImageID
current.SiteName, current.TitleTemplate, current.DefaultDescription = s.SiteName, s.TitleTemplate, s.DefaultDescription
m.sites[s.ID] = current
return m.withCount(current), nil
}
@@ -277,11 +278,12 @@ func newSiteEnv(t *testing.T) *siteEnv {
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, plainAuthors())
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
+14 -4
View File
@@ -173,7 +173,10 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
delete(after.schema, name)
}
}
for _, name := range []string{"articles", "idx_articles_site_id"} {
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
}
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id"} {
delete(before.schema, name)
delete(after.schema, name)
}
@@ -181,21 +184,28 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
}
assertSiteColumnAdded(t, db)
delete(after.rows["articles"][0], "site_id")
if after.rows["articles"][0]["slug"] != "first" {
t.Errorf("the legacy article was not backfilled: %v", after.rows["articles"][0])
}
for _, column := range addedArticleColumns {
delete(after.rows["articles"][0], column)
}
if !reflect.DeepEqual(before.rows, after.rows) {
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
}
})
}
var addedArticleColumns = []string{"site_id", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper()
var columns []string
if err := db.Raw("SELECT name FROM pragma_table_info('articles') ORDER BY cid").Scan(&columns).Error; err != nil {
t.Fatalf("read article columns: %v", err)
}
if columns[len(columns)-1] != "site_id" {
t.Errorf("expected site_id to be the only added article column, got %v", columns)
if !reflect.DeepEqual(columns[len(columns)-len(addedArticleColumns):], addedArticleColumns) {
t.Errorf("expected the added article columns %v at the end, got %v", addedArticleColumns, columns)
}
for _, table := range []string{"sites", "authors", "article_authors"} {
var found int
+1 -1
View File
@@ -27,7 +27,7 @@ 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()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
+6 -6
View File
@@ -243,7 +243,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -255,7 +255,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
_, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -264,7 +264,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -276,7 +276,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
_, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -386,7 +386,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
err := uc.Delete(testCtx, 1)
if err != nil {
@@ -400,7 +400,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
err := uc.Delete(testCtx, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
+1 -1
View File
@@ -25,7 +25,7 @@ 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()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+1 -1
View File
@@ -217,7 +217,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
+93 -3
View File
@@ -93,6 +93,32 @@ const EXPECTED_PUBLIC = [
'GET /healthz',
]
const ARTICLE_LIST_KEYS = [
'id',
'title',
'body',
'body_format',
'is_online',
'site_id',
'slug',
'excerpt',
'created_at',
'updated_at',
]
const ARTICLE_SEO_KEYS = [
'slug',
'excerpt',
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
'seo_dismissed',
]
const ARTICLE_KEYS = [
'id',
'title',
@@ -100,6 +126,7 @@ const ARTICLE_KEYS = [
'body_format',
'is_online',
'site_id',
...ARTICLE_SEO_KEYS,
'created_at',
'updated_at',
]
@@ -113,6 +140,9 @@ const SITE_KEYS = [
'locale',
'is_default',
'article_count',
'site_name',
'title_template',
'default_description',
]
const MEDIA_ITEM_KEYS = [
'id',
@@ -300,11 +330,26 @@ describe('Response samples use the real domain JSON keys', () => {
expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list.items[0], ARTICLE_KEYS)
for (const item of list.items) {
expect(keysOf(item)).toEqual([...ARTICLE_LIST_KEYS].sort())
}
expect(list.items[0]).not.toHaveProperty('images')
expect(list.items[0]).not.toHaveProperty('status')
})
it('carries the twelve SEO fields on single reads and only excerpt and slug in the list', () => {
const single = parsed(find('GET /v1/article/{id}'))
for (const key of ARTICLE_SEO_KEYS) {
expect(single, key).toHaveProperty(key)
}
const item = parsed(find('GET /v1/article/')).items[0]
const absent = ARTICLE_SEO_KEYS.filter((key) => !['slug', 'excerpt'].includes(key))
expect(absent).toHaveLength(10)
for (const key of absent) {
expect(item, key).not.toHaveProperty(key)
}
})
it('returns articles from create, update, publish and unpublish', () => {
for (const route of [
'POST /v1/article/',
@@ -358,9 +403,16 @@ describe('Response samples use the real domain JSON keys', () => {
expect(list[0].is_default).toBe(true)
})
it('uses id, title and published for usage items', () => {
it('keeps id, title and published on usage items and adds type and fields', () => {
const usage = parsed(find('GET /v1/media/{id}/usage'))
expect(keysOf(usage[0])).toEqual(['id', 'published', 'title'])
expect(keysOf(usage[0])).toEqual(['fields', 'id', 'published', 'title', 'type'])
expect(usage.map((item: { type: string }) => item.type)).toEqual([
'article',
'author',
'site',
])
expect(keysOf(usage[1])).toEqual(['fields', 'id', 'title', 'type'])
expect(keysOf(usage[2])).toEqual(['fields', 'id', 'title', 'type'])
})
it('uses user and invite keys for the user list and invites', () => {
@@ -421,6 +473,18 @@ describe('Request bodies and query parameters', () => {
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([
['body', 'string', false],
['site_id', 'number', false],
['slug', 'string', false],
['excerpt', 'string', false],
['seo_title', 'string', false],
['seo_description', 'string', false],
['cover_image_id', 'number | null', false],
['cover_image_alt', 'string', false],
['social_title', 'string', false],
['social_description', 'string', false],
['social_image_id', 'number | null', false],
['canonical_url', 'string', false],
['noindex', 'boolean', false],
['seo_dismissed', 'string[]', false],
])
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
@@ -433,6 +497,32 @@ describe('Request bodies and query parameters', () => {
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
})
it('limits the SEO request fields like the handler validate tags', () => {
const rule = (name: string) =>
find('PATCH /v1/article/{id}').body.find((field) => field.name === name)!.rule
expect(rule('excerpt')).toContain('300')
expect(rule('seo_title')).toContain('120')
expect(rule('seo_description')).toContain('320')
expect(rule('cover_image_alt')).toContain('200')
expect(rule('social_title')).toContain('120')
expect(rule('social_description')).toContain('320')
expect(rule('canonical_url')).toContain('2048')
expect(rule('slug')).toContain('100')
expect(rule('cover_image_id')).toContain('null removes')
expect(rule('social_image_id')).toContain('null removes')
expect(rule('seo_dismissed')).toContain('no_au')
expect(find('PATCH /v1/article/{id}').bodySample).toMatchObject({ cover_image_id: 4 })
})
it('lists the slug conflict and the new error tokens on the article update', () => {
const captions = find('PATCH /v1/article/{id}').errors.map((error) => error.caption)
expect(captions).toContain('invalid_slug')
expect(captions).toContain('invalid_url')
expect(captions).toContain('image_not_found')
expect(captions.some((caption) => caption.startsWith('slug_taken'))).toBe(true)
expect(captions.some((caption) => caption.includes('X-Slug-Suggestion'))).toBe(true)
})
it('uses the multipart field "file" for the upload', () => {
const upload = find('POST /v1/media/')
expect(upload.bodyKind).toBe('multipart')
+140 -7
View File
@@ -180,11 +180,41 @@ const article = (overrides: Record<string, unknown> = {}) => ({
body_format: 'markdown',
is_online: true,
site_id: 1,
slug: 'how-we-price-a-bathroom-renovation',
excerpt: 'What drives the price of a bathroom renovation.',
seo_title: '',
seo_description: '',
cover_image_id: 4,
cover_image_alt: 'A tiled bathroom wall',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
seo_dismissed: [],
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
})
const LIST_ITEM_OMITTED_KEYS = [
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
'seo_dismissed',
]
const articleListItem = (overrides: Record<string, unknown> = {}) =>
Object.fromEntries(
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
)
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
filename: 'north-wall.jpg',
@@ -211,6 +241,9 @@ const site = (overrides: Record<string, unknown> = {}) => ({
locale: 'en',
is_default: true,
article_count: 30,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: 'Notes from a renovation site.',
...overrides,
})
@@ -263,12 +296,14 @@ const ownerOrAdminErrors = (...own: ApiError[]) => [
const articlePage = () => ({
items: [
article(),
article({
articleListItem(),
articleListItem({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
slug: 'choosing-tiles-for-a-small-bathroom',
excerpt: '',
updated_at: '2026-09-09T07:00:00Z',
}),
],
@@ -417,13 +452,20 @@ export const API_GROUPS: ApiGroup[] = [
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article, or move it to another site. Send body, site_id or both.',
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
...articleIdErrors,
e(404, 'site_not_found'),
e(
409,
'slug_taken (the X-Slug-Suggestion header holds the first free slug)',
),
e(422, 'invalid_url'),
),
),
json(article({ body: 'Updated text.', is_online: false })),
@@ -441,10 +483,88 @@ export const API_GROUPS: ApiGroup[] = [
name: 'site_id',
type: 'number',
required: false,
rule: 'The id of an existing site, 1 or more. null is refused. At least one of body and site_id is required.',
rule: 'The id of an existing site, 1 or more. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. An empty string clears it; null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'Only a flag: the article stays online. null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.',
},
],
bodySample: { body: 'Updated text.', site_id: 2 },
bodySample: {
body: 'Updated text.',
site_id: 2,
slug: 'bathroom-pricing',
excerpt: 'What drives the price.',
cover_image_id: 4,
},
},
),
endpoint(
@@ -602,10 +722,20 @@ export const API_GROUPS: ApiGroup[] = [
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles that use an image.',
'List the articles, authors and sites that use an image, with the places where each one uses it.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
json([
{
type: 'article',
id: 12,
title: ARTICLE_TITLE,
published: true,
fields: ['body', 'cover'],
},
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
]),
{ pathId: 4 },
),
endpoint(
@@ -868,6 +998,9 @@ export const API_GROUPS: ApiGroup[] = [
locale: null,
is_default: false,
article_count: 4,
site_name: '',
title_template: '%title% - %site%',
default_description: '',
}),
]),
),