feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+5986
-242
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
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)
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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{}}
|
||||
}
|
||||
@@ -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())}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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: '',
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user