From 0eb46b0d740e9d1452047665214802eea0eb736e Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Thu, 15 Oct 2026 19:30:29 +0200 Subject: [PATCH] feat: SEO backend data (1/4) --- api/dependecyInjection.go | 6 +- api/handlers/article/articleHandler.go | 211 +++- api/handlers/media/mediaHandler.go | 16 +- api/handlers/site/siteHandler.go | 14 + api/pkg/apperr/messages.go | 5 + api/pkg/validator/validator.go | 11 + docs/api.md | 75 +- docs/api/articles.md | 46 +- docs/architecture.md | 10 + docs/release-notes.md | 25 + features/activity-log-producers.feature | 3 +- features/authors_backend.feature | 5 +- features/authors_backend.qa.md | 2 +- features/seo_data_backend.feature | 784 +++++++++++++++ features/seo_data_backend.qa.md | 91 ++ features/sites_backend.feature | 5 +- features/sites_backend.qa.md | 2 +- internal/domain/article/articleDomain.go | 118 ++- .../article/articleRepositoryInterface.go | 37 +- internal/domain/audit/auditChanges.go | 39 +- internal/domain/audit/auditDomain.go | 117 ++- internal/domain/author/authorDomain.go | 62 +- .../author/authorRepositoryInterface.go | 1 + internal/domain/site/siteDomain.go | 29 +- internal/domain/slug/slugDomain.go | 65 ++ .../database/articleSlugBackfill.go | 87 ++ .../database/articleSlugSnapshot.go | 60 ++ .../persistence/database/migrate.go | 11 +- .../persistence/database/siteSeed.go | 5 + .../persistence/mappers/articleMapper.go | 75 +- .../persistence/mappers/siteMapper.go | 10 + .../persistence/models/articleModel.go | 26 +- .../persistence/models/siteModel.go | 3 + .../repositories/articleRepository.go | 176 +++- .../repositories/authorRepository.go | 8 + .../repositories/siteRepository.go | 12 +- .../infrastructure/useCases/CreateArticle.go | 29 +- .../infrastructure/useCases/CreateSite.go | 20 + .../infrastructure/useCases/DeleteMedia.go | 26 +- .../infrastructure/useCases/GetMediaUsage.go | 122 ++- internal/infrastructure/useCases/ListMedia.go | 24 +- .../infrastructure/useCases/MergeMedia.go | 22 +- .../infrastructure/useCases/UpdateArticle.go | 153 ++- .../infrastructure/useCases/UpdateSite.go | 30 +- .../infrastructure/useCases/articleSlug.go | 39 + .../infrastructure/useCases/auditFailures.go | 2 + .../infrastructure/useCases/authorSupport.go | 10 +- internal/infrastructure/useCases/purgePlan.go | 11 +- test/activity_log_producers_test.go | 4 +- test/api_keys_http_test.go | 6 +- test/article_body_format_test.go | 2 +- test/article_body_unlimited_http_test.go | 2 +- test/article_publishing_http_test.go | 2 +- test/article_test.go | 135 +++ test/article_title_rules_test.go | 2 +- test/articles_list_http_test.go | 2 +- test/articles_pagination_api_http_test.go | 6 +- test/author_persistence_test.go | 2 +- test/author_support_test.go | 21 +- test/authors_backend_articles_http_test.go | 11 +- test/edit_article_body_http_test.go | 2 +- test/image_test.go | 2 +- test/image_variants_env_test.go | 4 +- test/media_env_test.go | 6 +- test/media_library_http_test.go | 2 +- test/media_storage_orchestration_test.go | 6 +- test/not_found_errors_test.go | 2 +- test/public_article_listing_test.go | 2 +- test/public_article_read_test.go | 2 +- test/runtime_settings_injection_http_test.go | 6 +- test/seo_data_activity_http_test.go | 268 +++++ test/seo_data_article_http_test.go | 943 ++++++++++++++++++ test/seo_data_cleaner_persistence_test.go | 194 ++++ test/seo_data_cleaner_test.go | 353 +++++++ test/seo_data_media_http_test.go | 479 +++++++++ test/seo_data_persistence_test.go | 435 ++++++++ test/seo_data_site_http_test.go | 334 +++++++ test/seo_data_support_test.go | 158 +++ test/site_acceptance_test.go | 2 +- test/site_domain_test.go | 2 +- test/site_media_acceptance_test.go | 12 +- test/site_persistence_test.go | 10 +- test/site_support_test.go | 6 +- test/sqlite_schema_unchanged_test.go | 18 +- test/stale_token_invalidation_http_test.go | 2 +- test/use_case_baseline_test.go | 12 +- test/verified_account_required_http_test.go | 2 +- test/write_not_found_errors_test.go | 2 +- web/src/data/__tests__/apiCatalogue.spec.ts | 96 +- web/src/data/apiCatalogue.ts | 147 ++- 90 files changed, 6094 insertions(+), 350 deletions(-) create mode 100644 features/seo_data_backend.feature create mode 100644 features/seo_data_backend.qa.md create mode 100644 internal/domain/slug/slugDomain.go create mode 100644 internal/infrastructure/persistence/database/articleSlugBackfill.go create mode 100644 internal/infrastructure/persistence/database/articleSlugSnapshot.go create mode 100644 internal/infrastructure/useCases/articleSlug.go create mode 100644 test/seo_data_activity_http_test.go create mode 100644 test/seo_data_article_http_test.go create mode 100644 test/seo_data_cleaner_persistence_test.go create mode 100644 test/seo_data_cleaner_test.go create mode 100644 test/seo_data_media_http_test.go create mode 100644 test/seo_data_persistence_test.go create mode 100644 test/seo_data_site_http_test.go create mode 100644 test/seo_data_support_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 4b5ef1c..b5619ab 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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), diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 0ecba28..8ff1758 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -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 } - http.Error(w, "Failed to create article", http.StatusInternalServerError) + 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 + return true } - http.Error(w, "Internal server error", http.StatusInternalServerError) + 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, 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 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"` +type optionalImageID struct { + Set bool + Value *uint32 `validate:"omitempty,gte=1"` } -var updatableArticleFields = []string{"body", "site_id", "author_ids"} +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 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,15 +449,27 @@ 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 } updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, useCases.UpdateArticleCommand{ - Body: request.Body, - SiteID: request.SiteID, - AuthorIDs: request.AuthorIDs, - Format: format, + 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 { writeArticleErrorResponse(w, err) @@ -366,11 +523,11 @@ func parseSort(value string) domain.Sort { } type listResponse struct { - Items []articleResponse `json:"items"` - Total int `json:"total"` - Page int `json:"page"` - Size int `json:"size"` - Counts domain.Counts `json:"counts"` + Items []listItemResponse `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Counts domain.Counts `json:"counts"` } func intQueryParam(values url.Values, name string, fallback int) (int, error) { @@ -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 diff --git a/api/handlers/media/mediaHandler.go b/api/handlers/media/mediaHandler.go index 3e1d016..0ab3a12 100644 --- a/api/handlers/media/mediaHandler.go +++ b/api/handlers/media/mediaHandler.go @@ -90,9 +90,11 @@ func intQueryParam(values url.Values, name string, fallback int) (int, error) { } type usageResponse struct { - ID uint32 `json:"id"` - Title string `json:"title"` - Published bool `json:"published"` + Type string `json:"type"` + ID uint32 `json:"id"` + Title string `json:"title"` + 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) } diff --git a/api/handlers/site/siteHandler.go b/api/handlers/site/siteHandler.go index f91c064..fb271af 100644 --- a/api/handlers/site/siteHandler.go +++ b/api/handlers/site/siteHandler.go @@ -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) diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 8b8cb8e..b8028c7 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -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" diff --git a/api/pkg/validator/validator.go b/api/pkg/validator/validator.go index 1a5c4ec..9b62b5f 100644 --- a/api/pkg/validator/validator.go +++ b/api/pkg/validator/validator.go @@ -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 { diff --git a/docs/api.md b/docs/api.md index 75e929b..6244a28 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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=` 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 `-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": , "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": , +"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 diff --git a/docs/api/articles.md b/docs/api/articles.md index 64eb4e8..2ac314b 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -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 `-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 `

Plan

Replace the old deck.

`: | --- | --- | | `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: ` (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 | diff --git a/docs/architecture.md b/docs/architecture.md index 231a8d2..636326f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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-` (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`. diff --git a/docs/release-notes.md b/docs/release-notes.md index 9123d09..26d16e9 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -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/.md`. Publish with `tea releases create --tag --title " - " --note-file docs/releases/.md --prerelease --asset ` 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. diff --git a/features/activity-log-producers.feature b/features/activity-log-producers.feature index a4fc512..6f5ec39 100644 --- a/features/activity-log-producers.feature +++ b/features/activity-log-producers.feature @@ -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 diff --git a/features/authors_backend.feature b/features/authors_backend.feature index d35d30c..bfb7c47 100644 --- a/features/authors_backend.feature +++ b/features/authors_backend.feature @@ -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 diff --git a/features/authors_backend.qa.md b/features/authors_backend.qa.md index 00e03e5..d56feb0 100644 --- a/features/authors_backend.qa.md +++ b/features/authors_backend.qa.md @@ -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. diff --git a/features/seo_data_backend.feature b/features/seo_data_backend.feature new file mode 100644 index 0000000..0007521 --- /dev/null +++ b/features/seo_data_backend.feature @@ -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 "-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 + Then the status is 200 + And + + 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 + 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 + Then the status is + And the body is "" + 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 + 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 + 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 + Then the status is + 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 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": "", "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 diff --git a/features/seo_data_backend.qa.md b/features/seo_data_backend.qa.md new file mode 100644 index 0000000..91094fa --- /dev/null +++ b/features/seo_data_backend.qa.md @@ -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). diff --git a/features/sites_backend.feature b/features/sites_backend.feature index 5540c50..9af8795 100644 --- a/features/sites_backend.feature +++ b/features/sites_backend.feature @@ -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 diff --git a/features/sites_backend.qa.md b/features/sites_backend.qa.md index 93afcd8..ede8313 100644 --- a/features/sites_backend.qa.md +++ b/features/sites_backend.qa.md @@ -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. diff --git a/internal/domain/article/articleDomain.go b/internal/domain/article/articleDomain.go index 0486f52..580951d 100644 --- a/internal/domain/article/articleDomain.go +++ b/internal/domain/article/articleDomain.go @@ -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 { @@ -27,12 +113,28 @@ func ValidateTitle(title string) error { } type Article struct { - ID uint32 `json:"id"` - Title string `json:"title"` - Body string `json:"body"` - IsOnline bool `json:"is_online"` - SiteID uint32 `json:"site_id"` - Authors []author.Byline `json:"authors"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint32 `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + IsOnline bool `json:"is_online"` + SiteID uint32 `json:"site_id"` + Authors []author.Byline `json:"authors"` + 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 } diff --git a/internal/domain/article/articleRepositoryInterface.go b/internal/domain/article/articleRepositoryInterface.go index 59e2b6f..290475b 100644 --- a/internal/domain/article/articleRepositoryInterface.go +++ b/internal/domain/article/articleRepositoryInterface.go @@ -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 + 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) } diff --git a/internal/domain/audit/auditChanges.go b/internal/domain/audit/auditChanges.go index 13a9313..1c7f517 100644 --- a/internal/domain/audit/auditChanges.go +++ b/internal/domain/audit/auditChanges.go @@ -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 } diff --git a/internal/domain/audit/auditDomain.go b/internal/domain/audit/auditDomain.go index f0dfbf4..7f5c9fe 100644 --- a/internal/domain/audit/auditDomain.go +++ b/internal/domain/audit/auditDomain.go @@ -121,58 +121,71 @@ const ( ) const ( - SystemActorName = "System" - ReasonWrongLogin = "Wrong username or password." - MaxUserAgentLength = 255 - ParamRemoved = "removed" - ParamFrom = "from" - ParamTo = "to" - ParamMaxGBFrom = "max_gb_from" - ParamMaxGBTo = "max_gb_to" - ParamMerged = "merged" - ParamArticlesUpdated = "articles_updated" - ParamFailedGroups = "failed_groups" - ParamUpdateAvailable = "update_available" - ParamKind = "kind" - ParamPurgedFiles = "purged_files" - ParamPurgedBytes = "purged_bytes" - ParamFailedFiles = "failed_files" - ParamOlderThanDays = "older_than_days" - ParamGenerated = "generated" - ParamFailed = "failed" - ParamCauses = "causes" - ParamWidth = "width" - ParamHeight = "height" - ParamFit = "fit" - ParamFormat = "format" - ParamQuality = "quality" - ParamTag = "tag" - FieldTitle = "title" - FieldOnline = "online" - FieldBody = "body" - FieldFilename = "filename" - FieldDisplayName = "display_name" - FieldWidth = "width" - FieldHeight = "height" - FieldFit = "fit" - FieldFormat = "format" - FieldQuality = "quality" - FieldName = "name" - FieldBaseURL = "base_url" - FieldDefaultRobots = "default_robots" - FieldLocale = "locale" - FieldSocialImage = "default_social_image" - FieldSite = "site" - FieldSlug = "slug" - FieldBio = "bio" - FieldWebsite = "website" - FieldSocialLinks = "social_links" - FieldAvatar = "avatar" - FieldUser = "user" - FieldAuthors = "authors" - maxNameLength = 255 - maxActionLength = 64 - maxFailureReasonSize = 255 + SystemActorName = "System" + ReasonWrongLogin = "Wrong username or password." + MaxUserAgentLength = 255 + ParamRemoved = "removed" + ParamFrom = "from" + ParamTo = "to" + ParamMaxGBFrom = "max_gb_from" + ParamMaxGBTo = "max_gb_to" + ParamMerged = "merged" + ParamArticlesUpdated = "articles_updated" + ParamFailedGroups = "failed_groups" + ParamUpdateAvailable = "update_available" + ParamKind = "kind" + ParamPurgedFiles = "purged_files" + ParamPurgedBytes = "purged_bytes" + ParamFailedFiles = "failed_files" + ParamOlderThanDays = "older_than_days" + ParamGenerated = "generated" + ParamFailed = "failed" + ParamCauses = "causes" + ParamWidth = "width" + ParamHeight = "height" + ParamFit = "fit" + ParamFormat = "format" + ParamQuality = "quality" + ParamTag = "tag" + FieldTitle = "title" + FieldOnline = "online" + FieldBody = "body" + FieldFilename = "filename" + FieldDisplayName = "display_name" + FieldWidth = "width" + FieldHeight = "height" + FieldFit = "fit" + FieldFormat = "format" + FieldQuality = "quality" + FieldName = "name" + FieldBaseURL = "base_url" + FieldDefaultRobots = "default_robots" + FieldLocale = "locale" + FieldSocialImage = "default_social_image" + FieldSite = "site" + FieldSlug = "slug" + FieldBio = "bio" + FieldWebsite = "website" + FieldSocialLinks = "social_links" + 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 ) func ActorTypes() []ActorType { diff --git a/internal/domain/author/authorDomain.go b/internal/domain/author/authorDomain.go index 885ad93..42f8878 100644 --- a/internal/domain/author/authorDomain.go +++ b/internal/domain/author/authorDomain.go @@ -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) } diff --git a/internal/domain/author/authorRepositoryInterface.go b/internal/domain/author/authorRepositoryInterface.go index 35d3f75..af9e101 100644 --- a/internal/domain/author/authorRepositoryInterface.go +++ b/internal/domain/author/authorRepositoryInterface.go @@ -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) diff --git a/internal/domain/site/siteDomain.go b/internal/domain/site/siteDomain.go index df5ac03..37b03df 100644 --- a/internal/domain/site/siteDomain.go +++ b/internal/domain/site/siteDomain.go @@ -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) { diff --git a/internal/domain/slug/slugDomain.go b/internal/domain/slug/slugDomain.go new file mode 100644 index 0000000..a160eb6 --- /dev/null +++ b/internal/domain/slug/slugDomain.go @@ -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], "-") +} diff --git a/internal/infrastructure/persistence/database/articleSlugBackfill.go b/internal/infrastructure/persistence/database/articleSlugBackfill.go new file mode 100644 index 0000000..39656a2 --- /dev/null +++ b/internal/infrastructure/persistence/database/articleSlugBackfill.go @@ -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 +} diff --git a/internal/infrastructure/persistence/database/articleSlugSnapshot.go b/internal/infrastructure/persistence/database/articleSlugSnapshot.go new file mode 100644 index 0000000..419bf81 --- /dev/null +++ b/internal/infrastructure/persistence/database/articleSlugSnapshot.go @@ -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], "-") +} diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index daaab20..ee865c6 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -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 ( diff --git a/internal/infrastructure/persistence/database/siteSeed.go b/internal/infrastructure/persistence/database/siteSeed.go index 6013ab5..44236a0 100644 --- a/internal/infrastructure/persistence/database/siteSeed.go +++ b/internal/infrastructure/persistence/database/siteSeed.go @@ -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 +} diff --git a/internal/infrastructure/persistence/mappers/articleMapper.go b/internal/infrastructure/persistence/mappers/articleMapper.go index b3284c9..3510989 100644 --- a/internal/infrastructure/persistence/mappers/articleMapper.go +++ b/internal/infrastructure/persistence/mappers/articleMapper.go @@ -3,27 +3,76 @@ 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, - Title: m.Title, - Body: m.Body, - IsOnline: m.IsOnline, - SiteID: m.SiteID, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + ID: m.ID, + Title: m.Title, + 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, } } func ArticleToModel(a article.Article) models.Article { return models.Article{ - Title: a.Title, - Body: a.Body, - IsOnline: a.IsOnline, - SiteID: a.SiteID, - CreatedAt: a.CreatedAt, - UpdatedAt: a.UpdatedAt, + Title: a.Title, + 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, } } diff --git a/internal/infrastructure/persistence/mappers/siteMapper.go b/internal/infrastructure/persistence/mappers/siteMapper.go index dbf495b..615f8b1 100644 --- a/internal/infrastructure/persistence/mappers/siteMapper.go +++ b/internal/infrastructure/persistence/mappers/siteMapper.go @@ -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, } } diff --git a/internal/infrastructure/persistence/models/articleModel.go b/internal/infrastructure/persistence/models/articleModel.go index 8e1db8d..d7064e2 100644 --- a/internal/infrastructure/persistence/models/articleModel.go +++ b/internal/infrastructure/persistence/models/articleModel.go @@ -8,11 +8,23 @@ import ( type Article 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 `gorm:"index"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + ID uint32 `gorm:"primary_key;auto_increment;not_null"` + Title string + 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"` } diff --git a/internal/infrastructure/persistence/models/siteModel.go b/internal/infrastructure/persistence/models/siteModel.go index e222816..6e1001b 100644 --- a/internal/infrastructure/persistence/models/siteModel.go +++ b/internal/infrastructure/persistence/models/siteModel.go @@ -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"` } diff --git a/internal/infrastructure/persistence/repositories/articleRepository.go b/internal/infrastructure/persistence/repositories/articleRepository.go index ee532c1..27e4503 100644 --- a/internal/infrastructure/persistence/repositories/articleRepository.go +++ b/internal/infrastructure/persistence/repositories/articleRepository.go @@ -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 + } } - if update.SiteID != nil { - localArticle.SiteID = *update.SiteID + applyUpdate(&localArticle, update) + if update.Slug != nil || update.SiteID != nil { + if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil { + return err + } } 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{} diff --git a/internal/infrastructure/persistence/repositories/authorRepository.go b/internal/infrastructure/persistence/repositories/authorRepository.go index f5e867d..d54231c 100644 --- a/internal/infrastructure/persistence/repositories/authorRepository.go +++ b/internal/infrastructure/persistence/repositories/authorRepository.go @@ -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 { diff --git a/internal/infrastructure/persistence/repositories/siteRepository.go b/internal/infrastructure/persistence/repositories/siteRepository.go index 1bf2003..16a99d4 100644 --- a/internal/infrastructure/persistence/repositories/siteRepository.go +++ b/internal/infrastructure/persistence/repositories/siteRepository.go @@ -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 diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index 4a78c86..26ca394 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -66,12 +66,13 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle return article.Article{}, err } - created, err := g.articleRepository.Create(article.Article{ - Title: createArticle.Title, - Body: createArticle.Body, - IsOnline: false, - SiteID: siteID, - Authors: creditedAuthors, + 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) } diff --git a/internal/infrastructure/useCases/CreateSite.go b/internal/infrastructure/useCases/CreateSite.go index c7c4087..50b4c3a 100644 --- a/internal/infrastructure/useCases/CreateSite.go +++ b/internal/infrastructure/useCases/CreateSite.go @@ -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 +} diff --git a/internal/infrastructure/useCases/DeleteMedia.go b/internal/infrastructure/useCases/DeleteMedia.go index 03628fe..dbf2cd9 100644 --- a/internal/infrastructure/useCases/DeleteMedia.go +++ b/internal/infrastructure/useCases/DeleteMedia.go @@ -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 diff --git a/internal/infrastructure/useCases/GetMediaUsage.go b/internal/infrastructure/useCases/GetMediaUsage.go index c505d74..fccb3f5 100644 --- a/internal/infrastructure/useCases/GetMediaUsage.go +++ b/internal/infrastructure/useCases/GetMediaUsage.go @@ -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) } diff --git a/internal/infrastructure/useCases/ListMedia.go b/internal/infrastructure/useCases/ListMedia.go index 09b7745..441078a 100644 --- a/internal/infrastructure/useCases/ListMedia.go +++ b/internal/infrastructure/useCases/ListMedia.go @@ -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++ } } diff --git a/internal/infrastructure/useCases/MergeMedia.go b/internal/infrastructure/useCases/MergeMedia.go index 060af0b..e74af35 100644 --- a/internal/infrastructure/useCases/MergeMedia.go +++ b/internal/infrastructure/useCases/MergeMedia.go @@ -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) } } diff --git a/internal/infrastructure/useCases/UpdateArticle.go b/internal/infrastructure/useCases/UpdateArticle.go index 3ce65e5..48627da 100644 --- a/internal/infrastructure/useCases/UpdateArticle.go +++ b/internal/infrastructure/useCases/UpdateArticle.go @@ -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 - Format article.BodyFormat + 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 { diff --git a/internal/infrastructure/useCases/UpdateSite.go b/internal/infrastructure/useCases/UpdateSite.go index 2013dd0..746069c 100644 --- a/internal/infrastructure/useCases/UpdateSite.go +++ b/internal/infrastructure/useCases/UpdateSite.go @@ -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 diff --git a/internal/infrastructure/useCases/articleSlug.go b/internal/infrastructure/useCases/articleSlug.go new file mode 100644 index 0000000..d187dba --- /dev/null +++ b/internal/infrastructure/useCases/articleSlug.go @@ -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) +} diff --git a/internal/infrastructure/useCases/auditFailures.go b/internal/infrastructure/useCases/auditFailures.go index 638611b..19bee3d 100644 --- a/internal/infrastructure/useCases/auditFailures.go +++ b/internal/infrastructure/useCases/auditFailures.go @@ -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."}, diff --git a/internal/infrastructure/useCases/authorSupport.go b/internal/infrastructure/useCases/authorSupport.go index 12101c3..da06934 100644 --- a/internal/infrastructure/useCases/authorSupport.go +++ b/internal/infrastructure/useCases/authorSupport.go @@ -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))) } diff --git a/internal/infrastructure/useCases/purgePlan.go b/internal/infrastructure/useCases/purgePlan.go index e94b9eb..a53b274 100644 --- a/internal/infrastructure/useCases/purgePlan.go +++ b/internal/infrastructure/useCases/purgePlan.go @@ -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 } diff --git a/test/activity_log_producers_test.go b/test/activity_log_producers_test.go index 97b1fd9..18e2b87 100644 --- a/test/activity_log_producers_test.go +++ b/test/activity_log_producers_test.go @@ -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) diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 6828c0f..65defef 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -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), diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go index b3d2603..0f0a5cd 100644 --- a/test/article_body_format_test.go +++ b/test/article_body_format_test.go @@ -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, ) diff --git a/test/article_body_unlimited_http_test.go b/test/article_body_unlimited_http_test.go index 2acfa0d..64663ca 100644 --- a/test/article_body_unlimited_http_test.go +++ b/test/article_body_unlimited_http_test.go @@ -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, ) diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go index 421b37d..fd39f8d 100644 --- a/test/article_publishing_http_test.go +++ b/test/article_publishing_http_test.go @@ -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, ) diff --git a/test/article_test.go b/test/article_test.go index d3fe490..c087eed 100644 --- a/test/article_test.go +++ b/test/article_test.go @@ -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 diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index 7de7250..c768b08 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -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, ) diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go index 9c63491..e9bf8bf 100644 --- a/test/articles_list_http_test.go +++ b/test/articles_list_http_test.go @@ -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, ) diff --git a/test/articles_pagination_api_http_test.go b/test/articles_pagination_api_http_test.go index f0083a0..48d93fa 100644 --- a/test/articles_pagination_api_http_test.go +++ b/test/articles_pagination_api_http_test.go @@ -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) } } diff --git a/test/author_persistence_test.go b/test/author_persistence_test.go index 7fde41e..5f89633 100644 --- a/test/author_persistence_test.go +++ b/test/author_persistence_test.go @@ -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) } diff --git a/test/author_support_test.go b/test/author_support_test.go index 0e5e211..4b9a40a 100644 --- a/test/author_support_test.go +++ b/test/author_support_test.go @@ -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) diff --git a/test/authors_backend_articles_http_test.go b/test/authors_backend_articles_http_test.go index f1aaa97..4c0964a 100644 --- a/test/authors_backend_articles_http_test.go +++ b/test/authors_backend_articles_http_test.go @@ -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) } }) } diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go index 6e08549..c92e385 100644 --- a/test/edit_article_body_http_test.go +++ b/test/edit_article_body_http_test.go @@ -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, ) diff --git a/test/image_test.go b/test/image_test.go index e358d9f..21a7392 100644 --- a/test/image_test.go +++ b/test/image_test.go @@ -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{ diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index 1ce1041..e307ce2 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -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), diff --git a/test/media_env_test.go b/test/media_env_test.go index 87e0853..58c55cc 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -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()), diff --git a/test/media_library_http_test.go b/test/media_library_http_test.go index 74644c3..f28315f 100644 --- a/test/media_library_http_test.go +++ b/test/media_library_http_test.go @@ -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) }) diff --git a/test/media_storage_orchestration_test.go b/test/media_storage_orchestration_test.go index 901aabf..a996ea7 100644 --- a/test/media_storage_orchestration_test.go +++ b/test/media_storage_orchestration_test.go @@ -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) diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 353ddfd..6182cbc 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -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() diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index 5627cb6..619503c 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -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, ) diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index 8eebdb7..ba2c87f 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -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, ) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index d235af9..c5c407d 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -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()), } } diff --git a/test/seo_data_activity_http_test.go b/test/seo_data_activity_http_test.go new file mode 100644 index 0000000..7230507 --- /dev/null +++ b/test/seo_data_activity_http_test.go @@ -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, "") +} diff --git a/test/seo_data_article_http_test.go b/test/seo_data_article_http_test.go new file mode 100644 index 0000000..9e37090 --- /dev/null +++ b/test/seo_data_article_http_test.go @@ -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) + } + }) +} diff --git a/test/seo_data_cleaner_persistence_test.go b/test/seo_data_cleaner_persistence_test.go new file mode 100644 index 0000000..1aa9d6c --- /dev/null +++ b/test/seo_data_cleaner_persistence_test.go @@ -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) + } +} diff --git a/test/seo_data_cleaner_test.go b/test/seo_data_cleaner_test.go new file mode 100644 index 0000000..66a25bf --- /dev/null +++ b/test/seo_data_cleaner_test.go @@ -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) + } + }) +} diff --git a/test/seo_data_media_http_test.go b/test/seo_data_media_http_test.go new file mode 100644 index 0000000..9b05151 --- /dev/null +++ b/test/seo_data_media_http_test.go @@ -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) + } + }) + +} diff --git a/test/seo_data_persistence_test.go b/test/seo_data_persistence_test.go new file mode 100644 index 0000000..7ebcd0b --- /dev/null +++ b/test/seo_data_persistence_test.go @@ -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{}} +} diff --git a/test/seo_data_site_http_test.go b/test/seo_data_site_http_test.go new file mode 100644 index 0000000..de9155d --- /dev/null +++ b/test/seo_data_site_http_test.go @@ -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())} + } +} diff --git a/test/seo_data_support_test.go b/test/seo_data_support_test.go new file mode 100644 index 0000000..cd073b1 --- /dev/null +++ b/test/seo_data_support_test.go @@ -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) +} diff --git a/test/site_acceptance_test.go b/test/site_acceptance_test.go index 9f1941e..f541077 100644 --- a/test/site_acceptance_test.go +++ b/test/site_acceptance_test.go @@ -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) } diff --git a/test/site_domain_test.go b/test/site_domain_test.go index e063570..8158a9f 100644 --- a/test/site_domain_test.go +++ b/test/site_domain_test.go @@ -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}) diff --git a/test/site_media_acceptance_test.go b/test/site_media_acceptance_test.go index eb8b450..644867e 100644 --- a/test/site_media_acceptance_test.go +++ b/test/site_media_acceptance_test.go @@ -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) } }) } diff --git a/test/site_persistence_test.go b/test/site_persistence_test.go index aa40af1..02e1fdf 100644 --- a/test/site_persistence_test.go +++ b/test/site_persistence_test.go @@ -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) { diff --git a/test/site_support_test.go b/test/site_support_test.go index 4bc1262..7c2a8f3 100644 --- a/test/site_support_test.go +++ b/test/site_support_test.go @@ -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) diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index 491300d..e159dbc 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -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 diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index dddea30..ba028d7 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -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( diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index 385a64a..210642b 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -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) { diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index 20ab95d..cd8ec37 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -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, ) diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index 06e12db..bd5270d 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -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, ) diff --git a/web/src/data/__tests__/apiCatalogue.spec.ts b/web/src/data/__tests__/apiCatalogue.spec.ts index 15b8220..59408c3 100644 --- a/web/src/data/__tests__/apiCatalogue.spec.ts +++ b/web/src/data/__tests__/apiCatalogue.spec.ts @@ -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') diff --git a/web/src/data/apiCatalogue.ts b/web/src/data/apiCatalogue.ts index 70862ec..7a86ce1 100644 --- a/web/src/data/apiCatalogue.ts +++ b/web/src/data/apiCatalogue.ts @@ -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: '', }), ]), ),