feat: SEO backend output (2/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 21:48:55 +02:00
1 parent 0eb46b0d74
commit eb9876fde3
32 files changed
+3859 -65

No files matched your search

+6
View File
@@ -48,6 +48,9 @@ type UseCases struct {
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
ListSitemapUseCase *useCases.ListSitemapUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
ListAuthorsUseCase *useCases.ListAuthorsUseCase
CreateAuthorUseCase *useCases.CreateAuthorUseCase
@@ -328,6 +331,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
+38 -24
View File
@@ -16,6 +16,7 @@ import (
"log"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
@@ -79,8 +80,10 @@ type articleResponse struct {
type listItemResponse struct {
baseArticleResponse
Excerpt string `json:"excerpt"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
Slug string `json:"slug"`
SEOStatus string `json:"seo_status"`
SEOSuggestions int `json:"seo_suggestions"`
}
func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArticleResponse, error) {
@@ -135,12 +138,12 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl
}, nil
}
func toListItemResponse(article domain.Article, format domain.BodyFormat) (listItemResponse, error) {
func toListItemResponse(article domain.Article, summary domain.SEOSummary, 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
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
@@ -392,6 +395,22 @@ func hasDuplicates(ids []uint32) bool {
return false
}
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
known := 0
for name, value := range fields {
switch {
case slices.Contains(nonNullable, name):
if string(value) == "null" {
return 0, false
}
known++
case slices.Contains(nullableArticleFields, name):
known++
}
}
return known, true
}
func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
@@ -401,23 +420,8 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
if err := json.Unmarshal(raw, &fields); err != nil {
return UpdateArticleBody{}, false
}
known := 0
for _, name := range nonNullableArticleFields {
value, present := fields[name]
if !present {
continue
}
if string(value) == "null" {
return UpdateArticleBody{}, false
}
known++
}
for _, name := range nullableArticleFields {
if _, present := fields[name]; present {
known++
}
}
if known == 0 {
known, ok := countKnownFields(fields, nonNullableArticleFields)
if !ok || known == 0 {
return UpdateArticleBody{}, false
}
var request UpdateArticleBody
@@ -488,12 +492,16 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) callerIsVerified(r *http.Request) bool {
return isVerifiedCaller(r, h.GetUserUseCase)
}
func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool {
userId, ok := claims.UserId(r)
if !ok {
return false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
currentUser, err := getUser.GetUser(userId)
if err != nil {
return false
}
@@ -512,6 +520,7 @@ type listQuery struct {
Q string `validate:"max=200"`
Status string `validate:"omitempty,oneof=online draft"`
Site *string `validate:"omitempty,min=1"`
SEO string `validate:"omitempty,oneof=ready suggestions hidden"`
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
}
@@ -562,7 +571,11 @@ func readListQuery(values url.Values) (listQuery, error) {
}
siteKey = &key
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey}, nil
seoStatus := values.Get("seo")
if values.Has("seo") && seoStatus == "" {
return listQuery{}, errors.New("empty seo")
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, nil
}
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
@@ -586,6 +599,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
Status: query.Status,
Sort: parseSort(query.Sort),
SiteKey: query.Site,
SEO: query.SEO,
})
if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
@@ -594,7 +608,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range page.Items {
response, err := toListItemResponse(article, format)
response, err := toListItemResponse(article, page.SEO[article.ID], format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
+127
View File
@@ -0,0 +1,127 @@
package article
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
)
type SEOHandler struct {
ResolveUseCase *useCases.ResolveArticleSEOUseCase
CheckUseCase *useCases.CheckArticleSEOUseCase
GetUserUseCase *useCases.GetUserUseCase
}
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler {
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser}
}
type CheckArticleSEOBody struct {
Body *string `json:"body"`
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 checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return CheckArticleSEOBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
return CheckArticleSEOBody{}, false
}
known, ok := countKnownFields(fields, checkNonNullableFields)
if !ok || known != len(fields) {
return CheckArticleSEOBody{}, false
}
var request CheckArticleSEOBody
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
return CheckArticleSEOBody{}, false
}
return request, true
}
func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase))
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, resolved)
}
func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readCheckBody(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{
Body: request.Body,
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,
})
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, checked)
}
func writeSEOJson(w http.ResponseWriter, payload any) {
encoded, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(encoded); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"log"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
defaultSitemapPage = 1
defaultSitemapSize = 100
totalCountHeader = "X-Total-Count"
)
type SitemapHandler struct {
ListUseCase *useCases.ListSitemapUseCase
}
func NewSitemapHandler(list *useCases.ListSitemapUseCase) *SitemapHandler {
return &SitemapHandler{ListUseCase: list}
}
type sitemapQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100 500"`
}
type sitemapItem struct {
URL string `json:"url"`
ModifiedAt string `json:"modified_at"`
Noindex bool `json:"noindex"`
}
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readSitemapQuery(values url.Values) (sitemapQuery, error) {
page, err := sitemapIntParam(values, "page", defaultSitemapPage)
if err != nil {
return sitemapQuery{}, err
}
size, err := sitemapIntParam(values, "size", defaultSitemapSize)
if err != nil {
return sitemapQuery{}, err
}
return sitemapQuery{Page: page, Size: size}, nil
}
func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
query, err := readSitemapQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(chi.URLParam(r, "key"), query.Page, query.Size)
if err != nil {
writeSiteError(w, err)
return
}
items := make([]sitemapItem, 0, len(page.Entries))
for _, entry := range page.Entries {
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set(totalCountHeader, strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+8 -1
View File
@@ -57,9 +57,12 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase, container.GetUserUseCase)
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Get("/{id}/seo", seoHandler.Resolve)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token))
@@ -70,6 +73,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/seo/check", seoHandler.Check)
})
return r
}
@@ -180,6 +184,9 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
@@ -419,7 +426,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
ExposedHeaders: []string{"Link", "X-Total-Count"},
AllowCredentials: false,
MaxAge: 300,
}))
+20 -5
View File
@@ -10,8 +10,9 @@ The origin that serves the app, for example `https://cms.example`. Every path st
for example `https://cms.example/v1/article/`.
## Authentication
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
articles only.
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
and `GET /v1/site/{key}/sitemap`. They return online articles only; the list and the resolved SEO
object also answer drafts to a verified session or API key.
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
creator, with that user's role, and shows in full once.
@@ -21,14 +22,14 @@ for example `https://cms.example/v1/article/`.
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
## Routes a key can call (26)
## Routes a key can call (29)
| Group | Routes |
| --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings |
| Sites | GET /v1/site/ |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ |
## Activity log
@@ -155,6 +156,20 @@ those fields, a `null` value (except on `cover_image_id` and `social_image_id`)
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.
### Resolved SEO, check, list status and sitemap
RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads drafts | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
### Article SEO fields
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
+41
View File
@@ -36,12 +36,14 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
| `status` | `online` or `draft` | none |
| `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none |
| `seo` | `ready`, `suggestions` or `hidden`; any other value, an empty one included, is `400` | none |
| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` |
- `items` is `[]` when empty, never `null`. Each item is the article object below.
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
- `counts` ignores `q` and `status`, so tab counts do not move while searching. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
- A page beyond the last answers `200` with `items: []` and the real `total`.
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
@@ -117,6 +119,45 @@ Editing a slug that another article of the target site already uses (the new sit
Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug.
## Resolved SEO object
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys:
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`).
The fallback chain, first non-empty wins:
| Output | Chain | `fallbacks` value |
| --- | --- | --- |
| `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` |
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` |
| `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` |
| `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` |
| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none |
| `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none |
`og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty.
## SEO status and suggestions
`POST /v1/article/{id}/seo/check` and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters.
| Code | Field | Fires when | `params` |
| --- | --- | --- | --- |
| `title_long` | `seo_title` | the meta title (`seo_title`, else the title) is over 60 characters | `length`, `max` |
| `title_empty` | `seo_title` | the meta title is empty after trimming | none |
| `desc_short` | `seo_description` | the resolved description is under 70 characters | `length`, `min` |
| `desc_long` | `seo_description` | the resolved description is over 160 characters | `length`, `max` |
| `cover_alt` | `cover_image_alt` | there is a cover and its alt text is empty | none |
| `cover_none` | `cover_image_id` | there is no cover | none |
| `slug_long` | `slug` | the slug is over 75 characters | `length`, `max` |
| `no_au` | `author_ids` | no author is credited | none |
| `no_h` | `body` | the text has at least 1000 characters and no `h2` to `h6` | none |
| `body_alt` | `body` | an image of the body has no alt text | `count` |
| `short` | `body` | the text has fewer than 300 characters | `length`, `min` |
Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article).
## 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.
+7
View File
@@ -35,6 +35,13 @@ Articles and sites now store the data a website needs for search and sharing, an
- 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.
### New: SEO output
- `GET /v1/article/{id}/seo` returns the resolved search and sharing data of an article (title with the site template, description, canonical address, robots, Open Graph, Twitter, image, authors and ready JSON-LD), so a frontend can render its meta tags without reimplementing the fallback chain. Drafts answer verified callers only.
- `POST /v1/article/{id}/seo/check` evaluates an article without saving, with optional overrides, and reports the suggestions, the status and whether the slug is free.
- `GET /v1/article/` items carry `seo_status` and `seo_suggestions`, and the new `seo` parameter filters on the status.
- `GET /v1/site/{key}/sitemap` lists the published URLs of a site, paged, with the total in `X-Total-Count`, which is now exposed to browsers through CORS.
### 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.
+676
View File
@@ -0,0 +1,676 @@
Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap
As a frontend developer or the RenewCMS back-office editor
I want the resolved SEO data of an article, a non-persisting check, an SEO status on the article list and a sitemap per site
So that a frontend can render meta tags and a sitemap without reimplementing the fallback chain
Background:
Given the spec of record is docs/design/seo-slices.md (slice 2), docs/design/change-list.md ("SEO round"), docs/design/screens/editor.md, docs/design/screens/articles.md and docs/design/screens/integration.md
And this slice is backend only: no SPA screen and no change to docs/design
And it reuses the article and site SEO fields of features/seo_data_backend.feature and adds no column and no migration
And RenewCMS renders no page and injects no meta tag: it only returns data
And one rule set (a single use-case-level function over the article, its site, its authors and its body) feeds the list seo_status and seo_suggestions, the check endpoint and the suggestion-related parts of the resolver
And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short
And the soft ranges are: meta title 30 to 60 characters, description 70 to 160, slug "very long" above 75; they only produce suggestions and never block saving or publishing
And codes listed in the article's seo_dismissed are never reported and never counted
And the status of an article is "hidden" when noindex is true, otherwise "suggestions" when at least one non-dismissed suggestion remains, otherwise "ready"
And a caller is "verified" when the jwt cookie (or an API key) resolves to a user with is_verified true, and "public" otherwise, exactly as for GET /v1/article/{id}
And the fallback chain is: title = seo_title, then the article title, with the site title_template applied last; description = seo_description, then excerpt, then the first sentence of the body, then the site default_description; image = social_image_id, then cover_image_id, then the site default_social_image_id; canonical = canonical_url, then site base_url + "/" + slug; robots = "noindex, nofollow" when noindex is true, otherwise from the site default_robots; og and twitter title and description = social_title and social_description, then the resolved values
And the sample site 1 is key "renew", base_url "https://www.renew.example", site_name "Renew", title_template "%title% | %site%", default_description "Renovation notes from the Renew team.", default_robots "index", locale "en" and default_social_image_id 7 (path /static/uploadedImages/7.png)
And the sample article 12 is online on the site 1, titled "How we price a bathroom renovation", slug "how-we-price-a-bathroom-renovation", created 2026-09-01T08:00:00Z, updated 2026-09-10T09:30:00Z, with no SEO field set and the body "<p>We price by the square metre. Tiles cost extra.</p>"
And error responses are plain text from http.Error: 404 "Article not found" (apperr.ArticleNotFoundErrorMessage), 404 "site_not_found" (apperr.SiteNotFoundErrorMessage), 400 apperr.IdUint32ErrorMessage for a malformed id, 400 apperr.BodyErrorMessage for a malformed body or paging parameter
And request validation goes through validate tags and validator.Validate.Struct
And handlers reach articles, sites, authors and images only through use cases and domain interfaces, and no comment is added anywhere
Rule: GET /v1/article/{id}/seo returns the resolved object
Scenario: The object has the documented shape
When an anonymous caller sends GET /v1/article/12/seo
Then the status is 200
And the body has exactly the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld
And og has exactly the keys type, title, description, image, image_alt, url, site_name and locale
And twitter has exactly the keys card, title, description, image and image_alt
And image has exactly the keys url, alt and source
And fallbacks has exactly the keys title, description, image and canonical
And the body has no body, no id and no seo_dismissed key
Scenario: A bare article resolves every output through the fallbacks
When an anonymous caller sends GET /v1/article/12/seo
Then title is "How we price a bathroom renovation | Renew"
And description is "We price by the square metre."
And canonical_url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
And robots is "index, follow"
And image is {"url": "https://www.renew.example/static/uploadedImages/7.png", "alt": "", "source": "site"}
And og is {"type": "article", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": "", "url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "site_name": "Renew", "locale": "en"}
And twitter is {"card": "summary_large_image", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": ""}
And fallbacks is {"title": "title", "description": "body", "image": "site", "canonical": "site"}
And published_at is "2026-09-01T08:00:00Z" and modified_at is "2026-09-10T09:30:00Z"
Scenario Outline: The title chain and the template
Given the article 12 has seo_title "<seo_title>" and the site title_template is "<template>"
When an anonymous caller sends GET /v1/article/12/seo
Then title is "<title>"
And fallbacks.title is "<source>"
Examples:
| seo_title | template | title | source |
| | %title% \| %site% | How we price a bathroom renovation \| Renew | title |
| Bathroom costs | %title% \| %site% | Bathroom costs \| Renew | seo_title |
| Bathroom costs | %site% - %title% | Renew - Bathroom costs | seo_title |
| Bathroom costs | %title% | Bathroom costs | seo_title |
| Bathroom costs | News: %title% | News: Bathroom costs | seo_title |
Scenario Outline: An empty site_name leaves nothing in place of %site% and the title is trimmed
Given the site 1 has site_name "" and title_template "<template>"
And the article 12 has seo_title "Bathroom costs"
When an anonymous caller sends GET /v1/article/12/seo
Then title is "<title>"
Examples:
| template | title |
| %title% \| %site% | Bathroom costs \| |
| %site% %title% | Bathroom costs |
| %title% | Bathroom costs |
Scenario Outline: The description chain
Given the article 12 has seo_description "<seo_description>", excerpt "<excerpt>", the body "<body>" and the site default_description "<default>"
When an anonymous caller sends GET /v1/article/12/seo
Then description is "<description>"
And fallbacks.description is "<source>"
Examples:
| seo_description | excerpt | body | default | description | source |
| Own words. | A teaser. | <p>First one. Second.</p> | Site. | Own words. | seo_description |
| | A teaser. | <p>First one. Second.</p> | Site. | A teaser. | excerpt |
| | | <p>First one. Second.</p> | Site. | First one. | body |
| | | <p>No terminator here</p> | Site. | No terminator here | body |
| | | <h2>Title</h2><p>What? Yes.</p> | Site. | Title What? | body |
| | | | Site. | Site. | site |
| | | | | | none |
Scenario Outline: The image chain
Given the article 12 has social_image_id <social>, cover_image_id <cover> and cover_image_alt "<alt>", and the images 5, 6 and 7 exist
When an anonymous caller sends GET /v1/article/12/seo
Then image.url ends with "<path>", image.alt is "<image_alt>" and image.source is "<source>"
And og.image equals image.url and twitter.image equals image.url
And fallbacks.image is "<source>"
Examples:
| social | cover | alt | path | image_alt | source |
| 6 | 5 | A field | /static/uploadedImages/6.png | | social |
| null | 5 | A field | /static/uploadedImages/5.png | A field | cover |
| null | null | | /static/uploadedImages/7.png | | site |
Scenario: No image anywhere
Given the article 12 has no cover and no social image and the site has no default social image
When an anonymous caller sends GET /v1/article/12/seo
Then image is {"url": "", "alt": "", "source": "none"}
And og.image and twitter.image are ""
And twitter.card is "summary"
And fallbacks.image is "none"
Scenario Outline: Canonical and robots
Given the article 12 has canonical_url "<canonical>" and noindex <noindex>, and the site default_robots is "<robots>"
When an anonymous caller sends GET /v1/article/12/seo
Then canonical_url is "<resolved>" and og.url is "<resolved>"
And fallbacks.canonical is "<source>"
And robots is "<result>"
Examples:
| canonical | noindex | robots | resolved | source | result |
| | false | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | index, follow |
| | false | noindex | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
| https://other.example/a | false | index | https://other.example/a | canonical_url | index, follow |
| | true | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
Scenario: Social overrides apply to og and twitter only
Given the article 12 has social_title "Shared title", social_description "Shared text", seo_title "Meta title" and seo_description "Meta text"
When an anonymous caller sends GET /v1/article/12/seo
Then title is "Meta title | Renew" and description is "Meta text"
And og.title and twitter.title are "Shared title" and og.description and twitter.description are "Shared text"
Scenario: Social fields fall back to the resolved values independently
Given the article 12 has social_title "Shared title" and no social_description
When an anonymous caller sends GET /v1/article/12/seo
Then og.title is "Shared title" and og.description is the resolved description
Scenario: Authors carry the profile URL and only the social links in same_as
Given the article 12 credits the author 2 "Marta Alves" with website "https://marta.example" and links [{"type": "github", "url": "https://github.com/marta"}, {"type": "x", "url": "https://x.com/marta"}]
And the author 3 "Zoe Lambert" with no website and no links, credited after her
When an anonymous caller sends GET /v1/article/12/seo
Then authors is [{"name": "Marta Alves", "url": "https://marta.example", "same_as": ["https://github.com/marta", "https://x.com/marta"]}, {"name": "Zoe Lambert", "url": "", "same_as": []}]
And the website "https://marta.example" is not in any same_as
Scenario: An article without authors has an empty authors array
When an anonymous caller sends GET /v1/article/12/seo
Then authors is [] and json_ld has no author key
Scenario: json_ld is a ready Article object
Given the article 12 credits the author 2 "Marta Alves" as above and has the cover 5 with alt "A field"
When an anonymous caller sends GET /v1/article/12/seo
Then json_ld is {"@context": "https://schema.org", "@type": "Article", "headline": "How we price a bathroom renovation", "description": "We price by the square metre.", "image": ["https://www.renew.example/static/uploadedImages/5.png"], "author": [{"@type": "Person", "name": "Marta Alves", "url": "https://marta.example", "sameAs": ["https://github.com/marta", "https://x.com/marta"]}], "datePublished": "2026-09-01T08:00:00Z", "dateModified": "2026-09-10T09:30:00Z", "mainEntityOfPage": {"@type": "WebPage", "@id": "https://www.renew.example/how-we-price-a-bathroom-renovation"}}
Scenario: The headline uses the meta title without the site template
Given the article 12 has seo_title "Bathroom costs"
When an anonymous caller sends GET /v1/article/12/seo
Then json_ld.headline is "Bathroom costs" and title is "Bathroom costs | Renew"
Scenario: The object never carries the body
Given the body of the article 12 contains "square metre"
When an anonymous caller sends GET /v1/article/12/seo
Then no value of the response is the full body and the raw response contains no "<p>"
Scenario Outline: No format parameter is honoured
When an anonymous caller sends GET /v1/article/12/seo<query>
Then the status is 200
And the body equals the response without any query
Examples:
| query |
| ?format=html |
| ?format=json |
| ?format=pdf |
Scenario: Another site uses its own base URL, template and robots
Given the article 12 moves to the site 2 with base_url "https://journal.renew.example", title_template "%title%" and default_robots "noindex"
When an anonymous caller sends GET /v1/article/12/seo
Then canonical_url starts with "https://journal.renew.example/" and robots is "noindex, nofollow" and title has no site suffix
Rule: Drafts answer authenticated callers who can read the article
Scenario: An online article is public
When a caller without credentials sends GET /v1/article/12/seo
Then the status is 200
Scenario Outline: A draft is visible to verified callers only
Given the article 13 is a draft
When GET /v1/article/13/seo is sent with <credentials>
Then the status is <status>
And the body is <body>
Examples:
| credentials | status | body |
| the owner's jwt cookie | 200 | the resolved object |
| a verified admin's jwt cookie | 200 | the resolved object |
| a verified account with another role | 200 | the resolved object |
| a valid API key | 200 | the resolved object |
| no credentials | 404 | "Article not found" |
| an unverified account's jwt cookie | 404 | "Article not found" |
| a malformed jwt cookie | 404 | "Article not found" |
Scenario: A draft has no publication date
When the owner sends GET /v1/article/13/seo on a draft
Then published_at is null and modified_at is the article's updated_at
And json_ld has no datePublished
Scenario: Unpublishing hides the object from the public again
Given the article 12 is online
When it is unpublished and an anonymous caller sends GET /v1/article/12/seo
Then the status is 404 with body "Article not found"
Scenario: A missing article is not found
When any caller sends GET /v1/article/999/seo
Then the status is 404 with body "Article not found"
Scenario: A malformed id is refused
When any caller sends GET /v1/article/abc/seo
Then the status is 400 with body apperr.IdUint32ErrorMessage
Scenario: The read is idempotent and writes nothing
When the same request is sent twice
Then both responses are identical and no repository write and no activity event happen
Scenario: A repository failure answers 500
Given the article repository fails on its next call
When a caller sends GET /v1/article/12/seo
Then the status is 500
Rule: POST /v1/article/{id}/seo/check evaluates without saving
Scenario: The check answers the documented shape
Given the article 12 has the default state of the background
When the owner sends POST /v1/article/12/seo/check with {}
Then the status is 200
And the body has exactly the keys status, suggestions and slug
And every suggestion has exactly the keys code, field and params
And slug has exactly the keys available and suggestion
Scenario Outline: Each code fires on its condition
Given the article 12 is configured so that <condition>
When the owner sends POST /v1/article/12/seo/check with {}
Then suggestions contains the code "<code>" with field "<field>" and params <params>
Examples:
| condition | code | field | params |
| the seo_title is 74 characters | title_long | seo_title | {"length": 74, "max": 60} |
| the resolved title is empty after trimming | title_empty | seo_title | {} |
| the resolved description is 41 characters | desc_short | seo_description | {"length": 41, "min": 70} |
| the resolved description is 170 characters | desc_long | seo_description | {"length": 170, "max": 160} |
| the cover is set and cover_image_alt is "" | cover_alt | cover_image_alt | {} |
| cover_image_id is null | cover_none | cover_image_id | {} |
| the slug is 76 characters | slug_long | slug | {"length": 76, "max": 75} |
| no author is credited | no_au | author_ids | {} |
| the body has 1000 characters of text and no h2 to h6 | no_h | body | {} |
| the body has 3 images and one has an empty alt | body_alt | body | {"count": 1} |
| the body has 40 characters of text | short | body | {"length": 40, "min": 300} |
Scenario Outline: Soft ranges are inclusive at the edges
When the owner sends POST /v1/article/12/seo/check with <body>
Then the code <code> is <present>
Examples:
| body | code | present |
| seo_title of 60 characters | title_long | absent |
| seo_title of 61 characters | title_long | present |
| seo_description of 70 characters | desc_short | absent |
| seo_description of 69 characters | desc_short | present |
| seo_description of 160 characters | desc_long | absent |
| seo_description of 161 characters | desc_long | present |
| slug of 75 characters | slug_long | absent |
| slug of 76 characters | slug_long | present |
Scenario Outline: The body rules have inclusive edges
When the owner sends POST /v1/article/12/seo/check with a body of <text> characters of text, <headings>
Then the code <code> is <present>
Examples:
| text | headings | code | present |
| 999 | no heading | no_h | absent |
| 1000 | no heading | no_h | present |
| 1000 | one h2 | no_h | absent |
| 1000 | one h6 | no_h | absent |
| 1000 | only an h1 | no_h | present |
| 300 | no heading | short | absent |
| 299 | no heading | short | present |
Scenario: Lengths are counted in runes
When the owner sends POST /v1/article/12/seo/check with a seo_title of 60 characters that are 2 bytes each
Then title_long is absent
Scenario: The title range is measured on the meta title, not on the template output
Given the site title_template adds 30 characters
When the owner sends POST /v1/article/12/seo/check with a seo_title of 40 characters
Then title_long is absent
Scenario: Text length ignores tags
When the owner sends POST /v1/article/12/seo/check with a body of 200 characters of text wrapped in 400 characters of markup
Then the code short is present
Scenario: A fully prepared article is ready
Given the article 12 has a 45-character meta title, a 100-character description, a cover with alt text, a short slug, one author and a body with a heading, an image with alt text and 400 characters
When the owner sends POST /v1/article/12/seo/check with {}
Then status is "ready" and suggestions is []
Scenario: Body overrides replace saved values for the check only
Given the article 12 has seo_title "Short" saved
When the owner sends POST /v1/article/12/seo/check with {"seo_title": "<74 characters>"}
Then suggestions contains title_long
And the article 12 still has seo_title "Short"
Scenario Outline: Overrides can also clear a saved value
Given the article 12 has <saved>
When the owner sends POST /v1/article/12/seo/check with <body>
Then the code <code> is <present>
Examples:
| saved | body | code | present |
| cover_image_id 5, empty alt | {"cover_image_id": null} | cover_alt | absent |
| cover_image_id 5, empty alt | {"cover_image_alt": "A field"} | cover_alt | absent |
| cover_image_alt "A field", cover_image_id 5 | {"cover_image_alt": ""} | cover_alt | present |
| a 74-character seo_title and a 20-character title | {"seo_title": ""} | title_long | absent |
Scenario: A body override feeds the body rules and the description fallback
Given the article 12 has the saved body of the background and no seo_description and no excerpt
When the owner sends POST /v1/article/12/seo/check with {"body": "<p>Sentence one of exactly seventy characters, padded here to reach the minimum. Two.</p>"}
Then the code desc_short is absent
And the code short is present
And the article 12 still has its saved body
Scenario: A body override with images and headings
When the owner sends POST /v1/article/12/seo/check with a body holding 1000 characters, no heading and an image without alt
Then the codes no_h and body_alt are present
Scenario: The check is non-persisting
Given a snapshot of the article 12, its site and the activity log
When the owner sends POST /v1/article/12/seo/check with {"slug": "other", "noindex": true, "excerpt": "x", "body": "<p>x</p>", "seo_dismissed": ["short"]}
Then the article 12 is unchanged, updated_at included
And no repository write, no activity event and no media usage change happen
Scenario: Dismissed codes are filtered out
Given the article 12 has seo_dismissed ["cover_none", "no_au"] and would raise cover_none, no_au and short
When the owner sends POST /v1/article/12/seo/check with {}
Then suggestions has only the code "short"
Scenario: A dismissed override in the body filters the check
When the owner sends POST /v1/article/12/seo/check with {"seo_dismissed": ["short", "no_au", "cover_none", "no_h"]} on an article that would raise those four only
Then suggestions is [] and status is "ready"
Scenario: Dismissing everything leaves ready, noindex still hides
Given the article 12 has noindex true
When the owner sends POST /v1/article/12/seo/check with {"seo_dismissed": [<all 11 codes>]}
Then status is "hidden" and suggestions is []
Scenario Outline: The status
Given the article 12 has <state>
When the owner sends POST /v1/article/12/seo/check with {}
Then status is "<status>"
Examples:
| state | status |
| no suggestion and noindex false | ready |
| two suggestions and noindex false | suggestions |
| no suggestion and noindex true | hidden |
| two suggestions and noindex true | hidden |
Scenario: A noindex override gives hidden without saving
When the owner sends POST /v1/article/12/seo/check with {"noindex": true}
Then status is "hidden" and the article 12 still has noindex false
Scenario: Suggestions come in a fixed order
Given the article would raise short, title_long and no_au
When the owner sends POST /v1/article/12/seo/check with {}
Then the codes are in the order title_long, no_au, short
Scenario Outline: Slug availability on the article's site
Given the site 1 has the articles 12 with slug "how-we-price-a-bathroom-renovation" and 14 with slug "spring" and 15 with slug "spring-2", and the site 2 has an article with slug "autumn"
When the owner sends POST /v1/article/12/seo/check with <body>
Then slug is <slug>
Examples:
| body | slug |
| {} | {"available": true, "suggestion": null} |
| {"slug": "how-we-price-a-bathroom-renovation"} | {"available": true, "suggestion": null} |
| {"slug": "fresh"} | {"available": true, "suggestion": null} |
| {"slug": "autumn"} | {"available": true, "suggestion": null} |
| {"slug": "spring"} | {"available": false, "suggestion": "spring-3"} |
| {"slug": ""} | {"available": true, "suggestion": null} |
Scenario: A regenerated empty slug can be taken
Given another article of the site 1 already has the slug "how-we-price-a-bathroom-renovation" and the article 12 has slug "custom"
When the owner sends POST /v1/article/12/seo/check with {"slug": ""}
Then slug is {"available": false, "suggestion": "how-we-price-a-bathroom-renovation-2"}
Scenario: The suggestion fits the slug limit
Given another article of the site has a 100-character slug
When the owner sends POST /v1/article/12/seo/check with that slug
Then slug.suggestion is at most 100 characters and ends with "-2"
Scenario: A taken slug is not a refusal
When the owner sends POST /v1/article/12/seo/check with a taken slug
Then the status is 200 and not 409
Scenario Outline: The same validation as PATCH applies
When the owner sends POST /v1/article/12/seo/check with <field> <value>
Then the status is <status>
And the body is "<body>"
Examples:
| field | value | status | body |
| seo_title | 121 characters | 400 | the validator error text |
| seo_description | 321 characters | 400 | the validator error text |
| excerpt | 301 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 |
| slug | "Spring" | 400 | invalid_slug |
| slug | 101 characters | 400 | invalid_slug |
| canonical_url | "http://example.com/a" | 422 | invalid_url |
| cover_image_id | 999 (no such image) | 400 | image_not_found |
| social_image_id | 999 (no such image) | 400 | image_not_found |
| seo_dismissed | ["nope"] | 400 | the validator error text |
Scenario Outline: Malformed bodies are refused
When the owner sends POST /v1/article/12/seo/check with <body>
Then the status is 400
And the body is apperr.BodyErrorMessage
Examples:
| body |
| {not json |
| {"slug": null} |
| {"noindex": null} |
| {"seo_dismissed": null} |
| {"body": null} |
| {"noindex": "yes"} |
| {"colour": "x"} |
Scenario: An empty body object is a valid check of the saved state
When the owner sends POST /v1/article/12/seo/check with {}
Then the status is 200
Scenario Outline: Site and author overrides are not accepted
When the owner sends POST /v1/article/12/seo/check with <body>
Then the status is 400 with body apperr.BodyErrorMessage
Examples:
| body |
| {"site_id": 2} |
| {"author_ids": [2]} |
Scenario Outline: Anyone who can edit the article may check it
When POST /v1/article/12/seo/check is sent with <credentials>
Then the status is <status>
Examples:
| credentials | status |
| the owner's jwt cookie | 200 |
| a verified admin's jwt cookie | 200 |
| a verified account with another role | 200 |
| a valid API key | 200 |
| an unverified account's jwt cookie | 403 |
| no credentials | 401 |
Scenario: A draft can be checked
When the owner sends POST /v1/article/13/seo/check with {} on a draft
Then the status is 200
Scenario: A missing article is not found
When the owner sends POST /v1/article/999/seo/check with {}
Then the status is 404 with body "Article not found"
Scenario: A malformed id is refused
When the owner sends POST /v1/article/abc/seo/check with {}
Then the status is 400 with body apperr.IdUint32ErrorMessage
Scenario: Publishing is never blocked by suggestions
Given the check of the article 13 reports suggestions
When the owner sends PATCH /v1/article/13/publish
Then the status is 200
Rule: The article list carries the SEO status
Scenario: Items carry seo_status and seo_suggestions
Given the articles 1 (ready), 2 (2 suggestions), 3 (noindex true) and 4 (3 suggestions, one dismissed so 2 remain)
When a verified account sends GET /v1/article/
Then the item 1 has seo_status "ready" and seo_suggestions 0
And the item 2 has seo_status "suggestions" and seo_suggestions 2
And the item 3 has seo_status "hidden"
And the item 4 has seo_status "suggestions" and seo_suggestions 2
And every item has the existing fields, excerpt and slug, and no seo_dismissed key
Scenario: A hidden article still reports its suggestion count
Given the article 3 has noindex true and 2 non-dismissed suggestions
When a verified account sends GET /v1/article/
Then the item 3 has seo_status "hidden" and seo_suggestions 2
Scenario: The list and the check agree
Given the articles 1 to 4 above
When the list is read and the check is run for each article with {}
Then for each article seo_status equals the check status and seo_suggestions equals the number of check suggestions
Scenario: Anonymous callers see online items with their status
When a caller without credentials sends GET /v1/article/
Then each online item carries seo_status and seo_suggestions and no draft is listed
Scenario Outline: The seo filter
Given the articles 1 (ready), 2 (suggestions), 3 (hidden) and 4 (suggestions)
When a verified account sends GET /v1/article/?seo=<value>
Then the item ids are <ids>
And total is <total>
Examples:
| value | ids | total |
| ready | 1 | 1 |
| suggestions | 4,2 | 2 |
| hidden | 3 | 1 |
Scenario: The filter composes with search, status, site, sort and paging
Given 7 draft articles of the site "journal" titled "Tile ..." with suggestions, 3 online ones and 4 more on the site "renew"
When a verified account sends GET /v1/article/?seo=suggestions&q=tile&status=draft&site=journal&sort=title:asc&size=5&page=2
Then the page holds the 2 remaining items in title order and total is 7
And pages 1 and 2 share no id
Scenario: Counts stay unfiltered
Given 4 online and 2 draft articles of which 3 have suggestions
When a verified account sends GET /v1/article/?seo=suggestions
Then counts is {"all": 6, "online": 4, "draft": 2} and total is 3
Scenario: The filter is applied before paging
Given 30 articles of which 12 have suggestions
When a verified account sends GET /v1/article/?seo=suggestions&size=5&page=3
Then the page holds 2 items and total is 12
Scenario: The filter respects visibility
Given an offline article with suggestions
When a caller without credentials sends GET /v1/article/?seo=suggestions
Then the offline article is not listed and total ignores it
Scenario: Dismissed codes are never counted in the filter
Given an article whose only suggestion is dismissed
When a verified account sends GET /v1/article/?seo=suggestions
Then the article is not listed
Scenario Outline: An invalid seo value is refused
When a client sends GET /v1/article/?<query>
Then the status is 400
And the body is apperr.BodyErrorMessage
And the repository was not called
Examples:
| query |
| seo=maybe |
| seo= |
| seo=READY |
| seo=ready,hidden |
| seo=1 |
Scenario: Invalid format is still reported first
When a client sends GET /v1/article/?seo=maybe&format=pdf
Then the status is 400 and the body is "The format must be one of: markdown, html, json."
Scenario: The existing list behaviour is unchanged without the filter
Given the background articles
When a verified account sends GET /v1/article/
Then total, page, size, counts, ordering and the other item fields are as before this slice
Rule: GET /v1/site/{key}/sitemap lists published URLs
Scenario: The sitemap lists the online articles of one site
Given the site "renew" has the online articles 12 and 16, and a draft 13
And the site "journal" has the online article 20
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the status is 200
And the body is a JSON array of 2 items: {"url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "modified_at": "2026-09-10T09:30:00Z", "noindex": false} and the item of the article 16
And the header X-Total-Count is "2"
And each item has exactly the keys url, modified_at and noindex
Scenario: Noindex articles are included with the flag
Given the online article 16 has noindex true
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the item of the article 16 has "noindex": true
Scenario: The url uses the slug and not the canonical override
Given the online article 12 has canonical_url "https://other.example/a"
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the item url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
Scenario: Drafts are never listed
When a caller with a verified jwt cookie sends GET /v1/site/renew/sitemap
Then the draft 13 is not listed
Scenario: An unpublished article leaves the sitemap
Given the article 12 is unpublished
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the article 12 is not listed and X-Total-Count decreases by 1
Scenario: The sitemap is public
When a caller without credentials sends GET /v1/site/renew/sitemap
Then the status is 200
Scenario: An empty site answers an empty array
Given the site "renew" has only drafts
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the status is 200, the body is [] and X-Total-Count is "0"
Scenario Outline: Paging and the total header
Given the site "renew" has <articles> online articles
When an anonymous caller sends GET /v1/site/renew/sitemap<query>
Then the body holds <count> items
And X-Total-Count is "<articles>"
Examples:
| articles | query | count |
| 250 | | 100 |
| 250 | ?size=25 | 25 |
| 250 | ?page=3 | 50 |
| 250 | ?page=2&size=100 | 100 |
| 250 | ?size=500 | 250 |
| 250 | ?page=99&size=25 | 0 |
Scenario: Pages partition the sitemap in a stable order
Given 60 online articles
When pages 1, 2 and 3 are requested with size 25
Then they hold 25, 25 and 10 items in id ascending order with no repeat
Scenario Outline: Invalid paging answers 400
When a client sends GET /v1/site/renew/sitemap?<query>
Then the status is 400
And the body is apperr.BodyErrorMessage
Examples:
| query |
| page=0 |
| page=-1 |
| page=abc |
| page= |
| size=0 |
| size=10 |
| size=1000 |
| size=abc |
Scenario: An unknown key is not found
When a client sends GET /v1/site/nope/sitemap
Then the status is 404 with body "site_not_found"
Scenario: The key is not an id
When a client sends GET /v1/site/1/sitemap on a site whose key is "renew"
Then the status is 404
Scenario: The route does not disturb the owner-only site routes
When an admin sends PATCH /v1/site/2 with {"site_name": "x"}
Then the status is 403 with body "Only the owner can do this." as before
Scenario: The browser can read the total header
When a cross-origin client reads the response of GET /v1/site/renew/sitemap with CORS configured
Then Access-Control-Expose-Headers lists X-Total-Count and still lists Link
Scenario: A repository failure answers 500
Given the article repository fails on its next call
When a client sends GET /v1/site/renew/sitemap
Then the status is 500
Rule: Layering and documentation
Scenario: Layering is respected
Then use cases import no persistence, mailer, models, gorm, net/http or chi
And the resolver, the suggestion rules and the sitemap logic live in use cases or domain packages
And the domain packages import no GORM or HTTP
And handlers import no persistence and no repository
And only api/dependecyInjection.go builds the new use cases and api/router.go wires them
And no comment is added anywhere
Scenario: The docs describe the new surface
Then docs/api.md documents GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, the seo query parameter, the seo_status and seo_suggestions item fields, GET /v1/site/{key}/sitemap, its X-Total-Count header and the CORS exposure of that header
And docs/api/articles.md documents the resolved object, the fallback chain, the suggestion codes with their soft ranges and the draft visibility rule
And docs/release-notes.md announces the change
And web/src/data/apiCatalogue.ts lists the three new endpoints and the new list parameter and fields, and its spec and web/src/views/IntegrationView.spec.ts counts are updated
+88
View File
@@ -0,0 +1,88 @@
# QA procedure: SEO output backend (slice 2 of 4)
Spec: `features/seo_output_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. Success bodies are JSON. Assert exact key sets by decoding into `map[string]any` and comparing sorted key lists.
## Divergences from docs/design
`docs/design` is read-only; slices 3 and 4 align to the code.
- **published_at:** `created_at` for online articles, `null` for drafts; `modified_at` is `updated_at`. No new column, no migration (user decision).
- **Social image name:** the change-list fallback chain says `og_image_id`; the article field is `social_image_id`. The code name is used.
- **Sitemap envelope:** the sitemap answers a JSON array with the total in `X-Total-Count`, as integration.md says. Articles and media list routes answer an envelope without that header. The two conventions coexist. `X-Total-Count` is added to the CORS `ExposedHeaders` (alongside `Link`) and documented in docs/api.md.
- **Sitemap sizes:** 25, 50, 100, 500 with default 100 (integration.md), unlike the 5, 25, 50, 100 of the other paged routes. Order is id ascending.
- **Unknown site key:** the body is the existing token `site_not_found` (`apperr.SiteNotFoundErrorMessage`), not the copy "Site not found" of integration.md.
- **Check body:** accepts the 12 SEO fields and an optional `body`; `site_id` and `author_ids` answer 400. `{}` checks the saved state.
- **Thresholds:** `no_h` needs at least 1000 characters of plain text and no h2-h6; `short` is under 300 characters.
- **Status precedence:** noindex gives `hidden` regardless of suggestions; `seo_suggestions` still counts non-dismissed ones.
- **`%site%` with an empty `site_name`:** replaced by an empty string, then the title is trimmed (whitespace only).
- **same_as:** social link URLs only; the website is in `url` only; JSON-LD `sameAs` equals `same_as`.
## Setup
- New files `test/seo_output_*_test.go`. Fakes have unique names: `mockSeoOutArticleRepo` (articles with per-site slugs, author credits, list filters q, status, site, sort, seo with paging, a call counter, failure injection), `mockSeoOutSiteRepo` (sites with key lookup), `mockSeoOutAuthorRepo`, `mockSeoOutImageRepo` and the fake audit repository (reuse, with a counter that asserts no Append).
- Routers mirror `api/router.go`: the public `/{id}/seo` route and the authenticated `/{id}/seo/check` route on the article router, the public `/{key}/sitemap` route on the site router. 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 with another role, an unverified account, plus a malformed cookie. API-key requests use the key authentication path.
- Fixtures: the background sample site 1 and article 12, builders for variants (seo_title lengths, descriptions, covers, authors with website and links, bodies with headings and images). Fixed `created_at`/`updated_at` times; no clock reads, so no clock-sensitive items.
- Assertions read the fakes' call counters and stored state, never only the JSON.
## A. Resolver
1. Shape: exact key sets of the root, `og`, `twitter`, `image`, `fallbacks`; no `body`, `id` or `seo_dismissed`; raw response has no body text.
2. Bare article: every output through the fallbacks with the exact values of the background; `fallbacks` values; `published_at` and `modified_at`.
3. Tables: title chain and template variants; empty `site_name` with each template (trimmed); description chain including first-sentence extraction (tags stripped, entities, terminator or none, heading plus paragraph) and the empty chain; image chain (social, cover with alt, site, none) with `image`, `og.image`, `twitter.image`, `twitter.card`; canonical and robots (override, site default `index`/`noindex`, `noindex: true`).
4. Social overrides apply to og and twitter only; social title and description fall back independently.
5. Authors: ordered, `url` from website or `""`, `same_as` from links in order and never the website; no authors gives `[]` and no `author` key in json_ld.
6. JSON-LD: compare the full decoded object to the expected one; headline uses the meta title without the template; dates in RFC 3339; image omitted when none; `datePublished` omitted for drafts.
7. `?format=html|json|pdf` returns the identical body as the bare call; second site uses its own base_url, template and robots.
## B. Visibility
8. Credentials table for a draft: owner, admin, other verified role, API key give 200; none, unverified, malformed cookie give 404 `Article not found`. An online article is 200 for everyone.
9. Draft has `published_at` null and no `datePublished`. Unpublish makes the object 404 for anonymous; missing article 404; `abc` gives `apperr.IdUint32ErrorMessage`; read performs no write and no audit Append; repository failure gives 500.
## C. Check endpoint
10. Shape: exact keys of root, suggestion, `slug`.
11. Code table: one article per code with the exact `field` and `params` and the thresholds above.
12. Edge tables: title 60/61, description 70/69 and 160/161, slug 75/76, `no_h` 999/1000 with h2, h6, h1 only, `short` 299/300, rune counting with 2-byte characters, title measured before the template, text length ignoring markup.
13. Fully prepared article gives `ready` and `[]`; overrides replace saved values (set, clear, null image id, body); the saved article, its `updated_at`, the site, the media usage and the activity log are unchanged after every call.
14. Dismissed: saved codes filtered; override `seo_dismissed` filters; all 11 dismissed on a noindex article gives `hidden` with `[]`; fixed code order (title_long first, no_au before short).
15. Status table: ready, suggestions, hidden (noindex with and without suggestions); a `noindex` override gives `hidden` without saving.
16. Slug table: own slug, free, other site's, taken with `-3` suggestion where `-2` is also taken, empty string regeneration (free and taken), 100-character suggestion, taken slug is 200 not 409.
17. Validation table identical to PATCH (status and token); malformed and null bodies (including `body: null`) give 400 `apperr.BodyErrorMessage`; `{}` gives 200; `site_id` and `author_ids` give 400.
18. Credentials: owner, admin, other verified role, API key give 200; unverified 403; none 401; draft 200; missing 404; `abc` `IdUint32ErrorMessage`.
19. Publishing a draft with suggestions still returns 200.
## D. List
20. `seo_status` and `seo_suggestions` per item for ready, suggestions, hidden and a partly dismissed article; no `seo_dismissed` key on items; existing fields unchanged.
21. Agreement test: for each fixture article, list values equal the check result of the same use case (guards the one-rule-set requirement).
22. Filter table `ready`, `suggestions`, `hidden` (ids and totals); composition with `q`, `status`, `site`, `sort` and `page`/`size`; pages disjoint and stable; counts unchanged; filter applied before paging; anonymous caller sees online items only; dismissed codes not counted.
23. Invalid table (`maybe`, empty, `READY`, `ready,hidden`, `1`) gives 400 `apperr.BodyErrorMessage` and the repository counter stays 0; `format=pdf` is reported first; no-filter behaviour unchanged.
## E. Sitemap
24. Only online articles of the key; `X-Total-Count` equals the filtered total; exact item key set; `noindex` flag included; url from slug and base_url; drafts excluded even with a verified cookie; unpublish removes.
25. Public: no credentials gives 200; empty site gives `[]` and `0`.
26. Paging table (250 articles: default 100, size 25, page 3, size 500, out of range gives empty array and the real total); 60 articles partition into 25, 25, 10 in id order with no overlap.
27. Invalid table (`page=0`, `-1`, `abc`, empty, `size=0`, `10`, `1000`, `abc`) gives 400 `apperr.BodyErrorMessage` and the repository counter stays 0.
28. Unknown key gives 404 `site_not_found`; an id as the key gives 404; PATCH `/v1/site/{id}` still owner only; repository failure gives 500.
29. CORS: with the CORS middleware enabled, `Access-Control-Expose-Headers` lists `X-Total-Count` and `Link`.
## F. Quality gates
30. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; domain packages import no GORM or HTTP; handlers import no persistence; `api/dependecyInjection.go` builds the new use cases and `api/router.go` wires them; validation uses validate tags through `validator.Validate.Struct`; no comments added.
31. Scoped mutation run through `task mutate` only, on the new use cases (resolver, suggestion rules, check, list status, sitemap), e.g. `MUTATE_FILES=<file names> MUTATE_RUN='TestSeoOutput' MUTATE_WORKERS=2 task mutate -- <use case package path>`. Report LIVED and NOT COVERED mutants and re-check filtered suspects without `MUTATE_RUN`. Boundary mutants (60/61, 70/69, 160/161, 75/76, 999/1000, 299/300) and the fallback-chain order are the expected high-value targets. Also `task lint:go` and `task fmt`.
## Non-behavioural deliverables (checked by reading)
- `docs/api.md`: the three new endpoints, the `seo` list parameter, the `seo_status` and `seo_suggestions` fields, the `X-Total-Count` header on the sitemap and its CORS exposure.
- `docs/api/articles.md`: the resolved object, fallback chain, codes and soft ranges, draft visibility.
- `docs/release-notes.md`: announcement.
- `web/src/data/apiCatalogue.ts`: entries for `GET /v1/article/{id}/seo`, `POST /v1/article/{id}/seo/check`, `GET /v1/site/{key}/sitemap`, plus the `seo` list parameter and the two list fields; its spec and `web/src/views/IntegrationView.spec.ts` counts updated. Run under `task test:web`; report NOT RUN if the runner is not installed.
## Manual
- Real boot against a database with slice 1 data: `GET /v1/article/{id}/seo` for a published article, a draft with and without a session, the sitemap for the default site and a second site.
- Cross-origin check that `X-Total-Count` is readable when CORS is configured.
- Paste a resolved `json_ld` into a schema.org / Rich Results validator.
- Not covered here: the SPA (slices 3 and 4).
@@ -14,6 +14,7 @@ const (
SortByStatus SortField = "status"
SortByCreatedAt SortField = "created_at"
SortByUpdatedAt SortField = "updated_at"
SortByID SortField = "id"
SortAsc SortDirection = "asc"
SortDesc SortDirection = "desc"
@@ -36,6 +37,7 @@ type Filters struct {
Q string
Status string
Sort Sort
Unpaged bool
}
type Nullable[T any] struct {
+91
View File
@@ -0,0 +1,91 @@
package article
const (
SEOSourceSEOTitle = "seo_title"
SEOSourceTitle = "title"
SEOSourceSEODescription = "seo_description"
SEOSourceExcerpt = "excerpt"
SEOSourceBody = "body"
SEOSourceSite = "site"
SEOSourceNone = "none"
SEOSourceSocial = "social"
SEOSourceCover = "cover"
SEOSourceCanonicalURL = "canonical_url"
)
type SEOOpenGraph struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
ImageAlt string `json:"image_alt"`
URL string `json:"url"`
SiteName string `json:"site_name"`
Locale string `json:"locale"`
}
type SEOTwitter struct {
Card string `json:"card"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
ImageAlt string `json:"image_alt"`
}
type SEOImage struct {
URL string `json:"url"`
Alt string `json:"alt"`
Source string `json:"source"`
}
type SEOAuthor struct {
Name string `json:"name"`
URL string `json:"url"`
SameAs []string `json:"same_as"`
}
type SEOFallbacks struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
Canonical string `json:"canonical"`
}
type SEOJSONLDPerson struct {
Type string `json:"@type"`
Name string `json:"name"`
URL string `json:"url"`
SameAs []string `json:"sameAs"`
}
type SEOJSONLDPage struct {
Type string `json:"@type"`
ID string `json:"@id"`
}
type SEOJSONLD struct {
Context string `json:"@context"`
Type string `json:"@type"`
Headline string `json:"headline"`
Description string `json:"description,omitempty"`
Image []string `json:"image,omitempty"`
Author []SEOJSONLDPerson `json:"author,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
DateModified string `json:"dateModified"`
MainEntityOfPage SEOJSONLDPage `json:"mainEntityOfPage"`
}
type ResolvedSEO struct {
Title string `json:"title"`
Description string `json:"description"`
CanonicalURL string `json:"canonical_url"`
Robots string `json:"robots"`
OG SEOOpenGraph `json:"og"`
Twitter SEOTwitter `json:"twitter"`
Image SEOImage `json:"image"`
Authors []SEOAuthor `json:"authors"`
PublishedAt *string `json:"published_at"`
ModifiedAt string `json:"modified_at"`
Fallbacks SEOFallbacks `json:"fallbacks"`
JSONLD SEOJSONLD `json:"json_ld"`
}
+53
View File
@@ -0,0 +1,53 @@
package article
const (
SEOStatusReady = "ready"
SEOStatusSuggestions = "suggestions"
SEOStatusHidden = "hidden"
SEOTitleMaxRunes = 60
SEODescriptionMinRunes = 70
SEODescriptionMaxRunes = 160
SEOSlugMaxRunes = 75
SEOHeadingMinTextRunes = 1000
SEOShortBodyRunes = 300
SEOFieldTitle = "seo_title"
SEOFieldDescription = "seo_description"
SEOFieldCoverAlt = "cover_image_alt"
SEOFieldCoverID = "cover_image_id"
SEOFieldSlug = "slug"
SEOFieldAuthors = "author_ids"
SEOFieldBody = "body"
)
type SEOSuggestion struct {
Code string `json:"code"`
Field string `json:"field"`
Params map[string]int `json:"params"`
}
type SEOAssessment struct {
Status string `json:"status"`
Suggestions []SEOSuggestion `json:"suggestions"`
}
type SEOSummary struct {
Status string
Suggestions int
}
func (a SEOAssessment) Summary() SEOSummary {
return SEOSummary{Status: a.Status, Suggestions: len(a.Suggestions)}
}
type SEOSlugCheck struct {
Available bool `json:"available"`
Suggestion *string `json:"suggestion"`
}
type SEOCheck struct {
Status string `json:"status"`
Suggestions []SEOSuggestion `json:"suggestions"`
Slug SEOSlugCheck `json:"slug"`
}
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"sort"
"strings"
"unicode"
@@ -59,6 +60,7 @@ var sortColumns = map[domain.SortField]string{
domain.SortByStatus: "is_online",
domain.SortByCreatedAt: "created_at",
domain.SortByUpdatedAt: "updated_at",
domain.SortByID: "id",
}
var sortDirections = map[domain.SortDirection]string{
@@ -72,6 +74,9 @@ func orderClause(sort domain.Sort) string {
if !ok || !directionOk {
column, direction = sortColumns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
}
if sort.Field == domain.SortByID {
return column + " " + direction
}
return column + " " + direction + ", id DESC"
}
@@ -87,7 +92,11 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
return domain.PageResult{}, err
}
var entityArticles []entity.Article
err := a.scoped(filters).Order(orderClause(filters.Sort)).Limit(filters.Size).Offset((filters.Page - 1) * filters.Size).Find(&entityArticles).Error
ordered := a.scoped(filters).Order(orderClause(filters.Sort))
if !filters.Unpaged {
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
}
err := ordered.Find(&entityArticles).Error
if err != nil {
return domain.PageResult{}, err
}
@@ -110,16 +119,23 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
matchingIds = append(matchingIds, candidate.ID)
}
}
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds := matchingIds[start:end]
pageIds := matchingIds
if !filters.Unpaged {
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds = matchingIds[start:end]
}
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
if len(pageIds) == 0 {
return page, nil
}
var entityArticles []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", pageIds).Find(&entityArticles).Error; err != nil {
return domain.PageResult{}, err
for _, chunk := range chunkIDs(pageIds) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return domain.PageResult{}, err
}
entityArticles = append(entityArticles, loaded...)
}
byId := make(map[uint32]entity.Article, len(entityArticles))
for _, entityArticle := range entityArticles {
@@ -186,15 +202,35 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil
}
const idChunkSize = 500
func chunkIDs(ids []uint32) [][]uint32 {
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
for start := 0; start < len(ids); start += idChunkSize {
chunks = append(chunks, ids[start:min(start+idChunkSize, len(ids))])
}
return chunks
}
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
if len(articleIDs) == 0 {
return bylines, nil
}
var credits []entity.ArticleAuthor
if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil {
return nil, err
for _, chunk := range chunkIDs(articleIDs) {
var loaded []entity.ArticleAuthor
if err := db.Where("article_id IN ?", chunk).Order("position ASC, id ASC").Find(&loaded).Error; err != nil {
return nil, err
}
credits = append(credits, loaded...)
}
sort.SliceStable(credits, func(i, j int) bool {
if credits[i].Position != credits[j].Position {
return credits[i].Position < credits[j].Position
}
return credits[i].ID < credits[j].ID
})
if len(credits) == 0 {
return bylines, nil
}
@@ -203,8 +239,12 @@ func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.By
authorIDs = append(authorIDs, credit.AuthorID)
}
var found []entity.Author
if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil {
return nil, err
for _, chunk := range chunkIDs(authorIDs) {
var loaded []entity.Author
if err := db.Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
}
byID := make(map[uint32]entity.Author, len(found))
for _, model := range found {
@@ -0,0 +1,130 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
)
type CheckArticleSEOUseCase struct {
articleRepository article.Repository
sites site.Repository
images image.Repository
}
type CheckArticleSEOCommand struct {
Body *string
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
}
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *CheckArticleSEOUseCase {
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
}
func (u *CheckArticleSEOUseCase) Check(id uint32, command CheckArticleSEOCommand) (article.SEOCheck, error) {
saved, err := u.articleRepository.Get(id)
if err != nil {
return article.SEOCheck{}, err
}
if err := u.validate(command); err != nil {
return article.SEOCheck{}, err
}
owner, err := u.sites.Get(saved.SiteID)
if err != nil {
return article.SEOCheck{}, err
}
candidate := command.applyTo(saved)
slugCheck, err := u.checkSlug(candidate)
if err != nil {
return article.SEOCheck{}, err
}
assessment := assessSEO(candidate, owner)
return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil
}
func (u *CheckArticleSEOUseCase) validate(command CheckArticleSEOCommand) 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(u.images, command.CoverImageID.Value); err != nil {
return err
}
}
if command.SocialImageID.Set {
return ensureImageExists(u.images, command.SocialImageID.Value)
}
return nil
}
func overrideText(current string, override *string) string {
if override == nil {
return current
}
return *override
}
func overrideImage(current *uint32, override article.Nullable[uint32]) *uint32 {
if override.Set {
return override.Value
}
return current
}
func (c CheckArticleSEOCommand) applyTo(saved article.Article) article.Article {
saved.Body = overrideText(saved.Body, c.Body)
saved.Excerpt = overrideText(saved.Excerpt, c.Excerpt)
saved.SEOTitle = overrideText(saved.SEOTitle, c.SEOTitle)
saved.SEODescription = overrideText(saved.SEODescription, c.SEODescription)
saved.CoverImageID = overrideImage(saved.CoverImageID, c.CoverImageID)
saved.CoverImageAlt = overrideText(saved.CoverImageAlt, c.CoverImageAlt)
saved.SocialTitle = overrideText(saved.SocialTitle, c.SocialTitle)
saved.SocialDescription = overrideText(saved.SocialDescription, c.SocialDescription)
saved.SocialImageID = overrideImage(saved.SocialImageID, c.SocialImageID)
saved.CanonicalURL = overrideText(saved.CanonicalURL, c.CanonicalURL)
if c.Noindex != nil {
saved.Noindex = *c.Noindex
}
if c.SEODismissed != nil {
saved.SEODismissed = article.NormalizeDismissed(*c.SEODismissed)
}
if c.Slug != nil {
saved.Slug = *c.Slug
if saved.Slug == "" {
saved.Slug = article.GenerateSlug(saved.Title)
}
}
return saved
}
func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.SEOSlugCheck, error) {
available := article.SEOSlugCheck{Available: true}
if candidate.Slug == "" {
return available, nil
}
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Slug, candidate.ID)
if err != nil || !taken {
return available, err
}
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Slug, candidate.ID, firstSuggestedAttempt)
if err != nil {
return article.SEOSlugCheck{}, err
}
return article.SEOSlugCheck{Suggestion: &suggestion}, nil
}
@@ -25,6 +25,7 @@ type ListArticlesCommand struct {
Status string
Sort article.Sort
SiteKey *string
SEO string
}
type ArticlePage struct {
@@ -33,6 +34,7 @@ type ArticlePage struct {
Page int
Size int
Counts article.Counts
SEO map[uint32]article.SEOSummary
}
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
@@ -40,7 +42,7 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
if err != nil {
return ArticlePage{}, err
}
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts}
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
if !command.Verified && command.Status == article.StatusDraft {
return page, nil
}
@@ -55,19 +57,69 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
}
filters.SiteID = &siteID
}
filters.Unpaged = command.SEO != ""
found, err := u.articleRepository.FindPage(filters)
if err != nil {
return ArticlePage{}, err
}
rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
summaries, err := u.summarize(found.Items)
if err != nil {
return ArticlePage{}, err
}
items, total := found.Items, found.Total
if command.SEO != "" {
items, total = pageBySEOStatus(found.Items, summaries, command)
}
rendered, err := renderArticles(u.bodyRenderer, items, command.Format)
if err != nil {
return ArticlePage{}, err
}
page.Items = rendered
page.Total = found.Total
page.Total = total
page.SEO = summaries
return page, nil
}
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
matching := make([]article.Article, 0, len(items))
for _, item := range items {
if summaries[item.ID].Status == command.SEO {
matching = append(matching, item)
}
}
size := min(max(command.Size, 0), len(matching))
if size == 0 {
return []article.Article{}, len(matching)
}
pages := (len(matching) + size - 1) / size
pageIndex := max(command.Page, 1) - 1
if pageIndex >= pages {
return []article.Article{}, len(matching)
}
start := pageIndex * size
end := min(start+size, len(matching))
return matching[start:end], len(matching)
}
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
summaries := make(map[uint32]article.SEOSummary, len(items))
if len(items) == 0 {
return summaries, nil
}
known, err := u.sites.List()
if err != nil {
return nil, err
}
owners := make(map[uint32]site.Site, len(known))
for _, owner := range known {
owners[owner.ID] = owner
}
for _, item := range items {
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
}
return summaries, nil
}
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
found, err := u.sites.GetByKey(key)
if errors.Is(err, site.ErrSiteNotFound) {
@@ -0,0 +1,50 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"time"
)
type ListSitemapUseCase struct {
articleRepository article.Repository
sites site.Repository
}
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository) *ListSitemapUseCase {
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites}
}
type SitemapEntry struct {
URL string
ModifiedAt time.Time
Noindex bool
}
type SitemapPage struct {
Entries []SitemapEntry
Total int
}
func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPage, error) {
owner, err := u.sites.GetByKey(siteKey)
if err != nil {
return SitemapPage{}, err
}
online := true
found, err := u.articleRepository.FindPage(article.Filters{
IsOnline: &online,
SiteID: &owner.ID,
Page: page,
Size: size,
Sort: article.Sort{Field: article.SortByID, Direction: article.SortAsc},
})
if err != nil {
return SitemapPage{}, err
}
entries := make([]SitemapEntry, 0, len(found.Items))
for _, item := range found.Items {
entries = append(entries, SitemapEntry{URL: articleURL(owner, item.Slug), ModifiedAt: item.UpdatedAt, Noindex: item.Noindex})
}
return SitemapPage{Entries: entries, Total: found.Total}, nil
}
@@ -0,0 +1,185 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"errors"
"strings"
"time"
)
const (
openGraphArticleType = "article"
twitterCardLarge = "summary_large_image"
twitterCardSmall = "summary"
robotsIndexed = "index, follow"
robotsNotIndexed = "noindex, nofollow"
jsonLDContext = "https://schema.org"
jsonLDArticle = "Article"
jsonLDPerson = "Person"
jsonLDWebPage = "WebPage"
sitePlaceholder = "%site%"
)
type ResolveArticleSEOUseCase struct {
articleRepository article.Repository
sites site.Repository
images image.Repository
}
func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *ResolveArticleSEOUseCase {
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
}
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, verified bool) (article.ResolvedSEO, error) {
art, err := u.articleRepository.Get(id)
if err != nil {
return article.ResolvedSEO{}, err
}
if !art.IsOnline && !verified {
return article.ResolvedSEO{}, article.ErrArticleNotFound
}
owner, err := u.sites.Get(art.SiteID)
if err != nil {
return article.ResolvedSEO{}, err
}
picked, err := u.resolveImage(art, owner)
if err != nil {
return article.ResolvedSEO{}, err
}
return buildResolvedSEO(art, owner, picked), nil
}
func (u *ResolveArticleSEOUseCase) resolveImage(art article.Article, owner site.Site) (article.SEOImage, error) {
candidates := []struct {
id *uint32
alt string
source string
}{
{art.SocialImageID, "", article.SEOSourceSocial},
{art.CoverImageID, art.CoverImageAlt, article.SEOSourceCover},
{owner.DefaultSocialImageID, "", article.SEOSourceSite},
}
for _, candidate := range candidates {
if candidate.id == nil {
continue
}
found, err := u.images.Get(*candidate.id)
if errors.Is(err, image.ErrImageNotFound) {
continue
}
if err != nil {
return article.SEOImage{}, err
}
return article.SEOImage{URL: owner.BaseURL + found.Path, Alt: candidate.alt, Source: candidate.source}, nil
}
return article.SEOImage{Source: article.SEOSourceNone}, nil
}
func applyTitleTemplate(template string, title string, siteName string) string {
if template == "" {
template = site.DefaultTitleTemplate
}
replacer := strings.NewReplacer(site.TitlePlaceholder, title, sitePlaceholder, siteName)
return strings.TrimSpace(replacer.Replace(template))
}
func resolveRobots(art article.Article, owner site.Site) string {
if art.Noindex || owner.DefaultRobots == site.RobotsNoIndex {
return robotsNotIndexed
}
return robotsIndexed
}
func resolveCanonical(art article.Article, owner site.Site) (string, string) {
if art.CanonicalURL != "" {
return art.CanonicalURL, article.SEOSourceCanonicalURL
}
return articleURL(owner, art.Slug), article.SEOSourceSite
}
func articleURL(owner site.Site, slug string) string {
return owner.BaseURL + "/" + slug
}
func resolveAuthors(art article.Article) []article.SEOAuthor {
authors := make([]article.SEOAuthor, 0, len(art.Authors))
for _, byline := range art.Authors {
profile := ""
if byline.Website != nil {
profile = *byline.Website
}
sameAs := make([]string, 0, len(byline.SocialLinks))
for _, link := range byline.SocialLinks {
sameAs = append(sameAs, link.URL)
}
authors = append(authors, article.SEOAuthor{Name: byline.Name, URL: profile, SameAs: sameAs})
}
return authors
}
func formatSEOTime(moment time.Time) string {
return moment.UTC().Format(time.RFC3339)
}
func buildJSONLD(headline string, description string, picked article.SEOImage, authors []article.SEOAuthor, art article.Article, canonical string) article.SEOJSONLD {
ld := article.SEOJSONLD{
Context: jsonLDContext,
Type: jsonLDArticle,
Headline: headline,
Description: description,
DateModified: formatSEOTime(art.UpdatedAt),
MainEntityOfPage: article.SEOJSONLDPage{Type: jsonLDWebPage, ID: canonical},
}
if picked.URL != "" {
ld.Image = []string{picked.URL}
}
for _, person := range authors {
ld.Author = append(ld.Author, article.SEOJSONLDPerson{Type: jsonLDPerson, Name: person.Name, URL: person.URL, SameAs: person.SameAs})
}
if art.IsOnline {
ld.DatePublished = formatSEOTime(art.CreatedAt)
}
return ld
}
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage) article.ResolvedSEO {
meta, titleSource := metaTitle(art)
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body))
canonical, canonicalSource := resolveCanonical(art, owner)
authors := resolveAuthors(art)
shareTitle := firstNonEmpty(art.SocialTitle, title)
shareDescription := firstNonEmpty(art.SocialDescription, description)
card := twitterCardSmall
if picked.URL != "" {
card = twitterCardLarge
}
locale := ""
if owner.Locale != nil {
locale = *owner.Locale
}
var published *string
if art.IsOnline {
formatted := formatSEOTime(art.CreatedAt)
published = &formatted
}
return article.ResolvedSEO{
Title: title,
Description: description,
CanonicalURL: canonical,
Robots: resolveRobots(art, owner),
OG: article.SEOOpenGraph{
Type: openGraphArticleType, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt,
URL: canonical, SiteName: owner.SiteName, Locale: locale,
},
Twitter: article.SEOTwitter{Card: card, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt},
Image: picked,
Authors: authors,
PublishedAt: published,
ModifiedAt: formatSEOTime(art.UpdatedAt),
Fallbacks: article.SEOFallbacks{Title: titleSource, Description: descriptionSource, Image: picked.Source, Canonical: canonicalSource},
JSONLD: buildJSONLD(meta, description, picked, authors, art, canonical),
}
}
+101
View File
@@ -0,0 +1,101 @@
package useCases
import (
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
type bodyFacts struct {
spacedText string
textRunes int
hasSubheading bool
imagesWithoutAlt int
}
var blockElements = map[string]bool{
"p": true, "div": true, "br": true, "li": true, "ul": true, "ol": true, "blockquote": true, "pre": true,
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "tr": true, "section": true, "article": true,
"figure": true, "figcaption": true, "table": true, "hr": true,
}
var subheadings = map[string]bool{"h2": true, "h3": true, "h4": true, "h5": true, "h6": true}
var skippedElements = map[string]bool{"script": true, "style": true}
const sentenceTerminators = ".!?"
func analyzeBody(body string) bodyFacts {
tokenizer := html.NewTokenizer(strings.NewReader(body))
var plain, spaced strings.Builder
facts := bodyFacts{}
skipping := false
for {
kind := tokenizer.Next()
if kind == html.ErrorToken {
break
}
switch kind {
case html.TextToken:
if !skipping {
text := string(tokenizer.Text())
plain.WriteString(text)
spaced.WriteString(text)
}
case html.StartTagToken, html.SelfClosingTagToken, html.EndTagToken:
token := tokenizer.Token()
skipping = nextSkipping(skipping, kind, token)
if blockElements[token.Data] {
spaced.WriteString(" ")
}
if kind == html.EndTagToken {
continue
}
if subheadings[token.Data] {
facts.hasSubheading = true
}
if token.Data == "img" && !hasAlt(token) {
facts.imagesWithoutAlt++
}
}
}
facts.textRunes = utf8.RuneCountInString(strings.TrimSpace(plain.String()))
facts.spacedText = strings.Join(strings.Fields(spaced.String()), " ")
return facts
}
func nextSkipping(skipping bool, kind html.TokenType, token html.Token) bool {
if !skippedElements[token.Data] {
return skipping
}
switch kind {
case html.StartTagToken:
return true
case html.EndTagToken:
return false
}
return skipping
}
func hasAlt(token html.Token) bool {
for _, attribute := range token.Attr {
if attribute.Key == "alt" {
return strings.TrimSpace(attribute.Val) != ""
}
}
return false
}
func firstSentence(text string) string {
runes := []rune(text)
for index, character := range runes {
if !strings.ContainsRune(sentenceTerminators, character) {
continue
}
if index+1 == len(runes) || runes[index+1] == ' ' {
return string(runes[:index+1])
}
}
return text
}
@@ -0,0 +1,97 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"slices"
"strings"
"unicode/utf8"
)
func metaTitle(art article.Article) (string, string) {
if art.SEOTitle != "" {
return art.SEOTitle, article.SEOSourceSEOTitle
}
return art.Title, article.SEOSourceTitle
}
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (string, string) {
if art.SEODescription != "" {
return art.SEODescription, article.SEOSourceSEODescription
}
if art.Excerpt != "" {
return art.Excerpt, article.SEOSourceExcerpt
}
if sentence := firstSentence(facts.spacedText); sentence != "" {
return sentence, article.SEOSourceBody
}
if owner.DefaultDescription != "" {
return owner.DefaultDescription, article.SEOSourceSite
}
return "", article.SEOSourceNone
}
func suggestion(code string, field string, params map[string]int) article.SEOSuggestion {
return article.SEOSuggestion{Code: code, Field: field, Params: params}
}
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []article.SEOSuggestion {
suggestions := []article.SEOSuggestion{}
title, _ := metaTitle(art)
titleRunes := utf8.RuneCountInString(title)
if titleRunes > article.SEOTitleMaxRunes {
suggestions = append(suggestions, suggestion(article.DismissTitleLong, article.SEOFieldTitle, map[string]int{"length": titleRunes, "max": article.SEOTitleMaxRunes}))
}
if strings.TrimSpace(title) == "" {
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
}
description, _ := resolveDescription(art, owner, facts)
descriptionRunes := utf8.RuneCountInString(description)
if descriptionRunes < article.SEODescriptionMinRunes {
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
}
if descriptionRunes > article.SEODescriptionMaxRunes {
suggestions = append(suggestions, suggestion(article.DismissDescLong, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "max": article.SEODescriptionMaxRunes}))
}
if art.CoverImageID != nil && strings.TrimSpace(art.CoverImageAlt) == "" {
suggestions = append(suggestions, suggestion(article.DismissCoverAlt, article.SEOFieldCoverAlt, map[string]int{}))
}
if art.CoverImageID == nil {
suggestions = append(suggestions, suggestion(article.DismissCoverNone, article.SEOFieldCoverID, map[string]int{}))
}
slugRunes := utf8.RuneCountInString(art.Slug)
if slugRunes > article.SEOSlugMaxRunes {
suggestions = append(suggestions, suggestion(article.DismissSlugLong, article.SEOFieldSlug, map[string]int{"length": slugRunes, "max": article.SEOSlugMaxRunes}))
}
if len(art.Authors) == 0 {
suggestions = append(suggestions, suggestion(article.DismissNoAuthor, article.SEOFieldAuthors, map[string]int{}))
}
if facts.textRunes >= article.SEOHeadingMinTextRunes && !facts.hasSubheading {
suggestions = append(suggestions, suggestion(article.DismissNoHeading, article.SEOFieldBody, map[string]int{}))
}
if facts.imagesWithoutAlt > 0 {
suggestions = append(suggestions, suggestion(article.DismissBodyAlt, article.SEOFieldBody, map[string]int{"count": facts.imagesWithoutAlt}))
}
if facts.textRunes < article.SEOShortBodyRunes {
suggestions = append(suggestions, suggestion(article.DismissShort, article.SEOFieldBody, map[string]int{"length": facts.textRunes, "min": article.SEOShortBodyRunes}))
}
return suggestions
}
func assessSEO(art article.Article, owner site.Site) article.SEOAssessment {
facts := analyzeBody(art.Body)
kept := []article.SEOSuggestion{}
for _, found := range rawSuggestions(art, owner, facts) {
if !slices.Contains(art.SEODismissed, found.Code) {
kept = append(kept, found)
}
}
status := article.SEOStatusReady
if len(kept) > 0 {
status = article.SEOStatusSuggestions
}
if art.Noindex {
status = article.SEOStatusHidden
}
return article.SEOAssessment{Status: status, Suggestions: kept}
}
+5
View File
@@ -103,6 +103,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
start := min((filters.Page-1)*filters.Size, len(matching))
end := min(start+filters.Size, len(matching))
if filters.Unpaged {
start, end = 0, len(matching)
}
items := make([]article.Article, 0)
for _, a := range matching[start:end] {
items = append(items, m.withAuthors(a))
@@ -143,6 +146,8 @@ func articleSortsBefore(left, right article.Article, order article.Sort) bool {
}
case article.SortByCreatedAt:
comparison = left.CreatedAt.Compare(right.CreatedAt)
case article.SortByID:
comparison = int(left.ID) - int(right.ID)
default:
comparison = left.UpdatedAt.Compare(right.UpdatedAt)
}
+431
View File
@@ -0,0 +1,431 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"net/http"
"reflect"
"strings"
"testing"
)
func seoOutFullyPrepared(e *seoOutEnv) {
e.credit(seoOutAuthorMarta, "Marta Alves", "")
e.change(12, func(a *article.Article) {
a.SEOTitle = repeated("t", 45)
a.SEODescription = repeated("d", 100)
a.CoverImageID, a.CoverImageAlt = idPointer(5), "A field"
a.Body = textBody(400, `<h2>Heading</h2><img src="/x.png" alt="described">`)
})
}
func findSuggestion(checked map[string]any, code string) map[string]any {
for _, item := range checked["suggestions"].([]any) {
if found := item.(map[string]any); found["code"] == code {
return found
}
}
return nil
}
func TestSeoOutputCheckShapeAndCodes(t *testing.T) {
t.Run("The check answers the documented shape", func(t *testing.T) {
e := newSeoOutEnv(t)
checked := e.check(t, `{}`)
if sortedKeys(checked) != "slug,status,suggestions" || sortedKeys(nested(checked, "slug")) != "available,suggestion" {
t.Errorf("checked = %v", checked)
}
for _, item := range checked["suggestions"].([]any) {
if sortedKeys(item.(map[string]any)) != "code,field,params" {
t.Errorf("suggestion = %v", item)
}
}
})
t.Run("Each code fires on its condition", func(t *testing.T) {
cases := []struct {
name string
setup func(*seoOutEnv)
body string
code string
field string
params map[string]any
}{
{"title_long", nil, jsonBody(t, "seo_title", repeated("t", 74)), "title_long", "seo_title", map[string]any{"length": 74.0, "max": 60.0}},
{"title_empty", nil, `{"seo_title": " "}`, "title_empty", "seo_title", map[string]any{}},
{"desc_short", nil, jsonBody(t, "seo_description", repeated("d", 41)), "desc_short", "seo_description", map[string]any{"length": 41.0, "min": 70.0}},
{"desc_long", nil, jsonBody(t, "seo_description", repeated("d", 170)), "desc_long", "seo_description", map[string]any{"length": 170.0, "max": 160.0}},
{"cover_alt", func(e *seoOutEnv) { e.change(12, func(a *article.Article) { a.CoverImageID = idPointer(5) }) }, `{}`, "cover_alt", "cover_image_alt", map[string]any{}},
{"cover_none", nil, `{}`, "cover_none", "cover_image_id", map[string]any{}},
{"slug_long", nil, jsonBody(t, "slug", repeated("s", 76)), "slug_long", "slug", map[string]any{"length": 76.0, "max": 75.0}},
{"no_au", nil, `{}`, "no_au", "author_ids", map[string]any{}},
{"no_h", nil, jsonBody(t, "body", textBody(1000, "")), "no_h", "body", map[string]any{}},
{"body_alt", nil, jsonBody(t, "body", textBody(400, `<img src="a" alt="ok"><img src="b" alt=""><img src="c" alt="fine">`)), "body_alt", "body", map[string]any{"count": 1.0}},
{"short", nil, jsonBody(t, "body", textBody(40, "")), "short", "body", map[string]any{"length": 40.0, "min": 300.0}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
if c.setup != nil {
c.setup(e)
}
found := findSuggestion(e.check(t, c.body), c.code)
if found == nil || found["field"] != c.field || !reflect.DeepEqual(found["params"], c.params) {
t.Errorf("suggestion = %v", found)
}
})
}
})
t.Run("Soft ranges are inclusive at the edges", func(t *testing.T) {
cases := []struct {
body string
code string
present bool
}{
{jsonBody(t, "seo_title", repeated("t", 60)), "title_long", false},
{jsonBody(t, "seo_title", repeated("t", 61)), "title_long", true},
{jsonBody(t, "seo_description", repeated("d", 70)), "desc_short", false},
{jsonBody(t, "seo_description", repeated("d", 69)), "desc_short", true},
{jsonBody(t, "seo_description", repeated("d", 160)), "desc_long", false},
{jsonBody(t, "seo_description", repeated("d", 161)), "desc_long", true},
{jsonBody(t, "slug", repeated("s", 75)), "slug_long", false},
{jsonBody(t, "slug", repeated("s", 76)), "slug_long", true},
{jsonBody(t, "body", textBody(999, "")), "no_h", false},
{jsonBody(t, "body", textBody(1000, "")), "no_h", true},
{jsonBody(t, "body", textBody(1000, "<h2>x</h2>")), "no_h", false},
{jsonBody(t, "body", textBody(1000, "<h6>x</h6>")), "no_h", false},
{jsonBody(t, "body", textBody(1000, "<h1>x</h1>")), "no_h", true},
{jsonBody(t, "body", textBody(300, "")), "short", false},
{jsonBody(t, "body", textBody(299, "")), "short", true},
}
for _, c := range cases {
e := newSeoOutEnv(t)
if got := hasCode(e.checkCodes(t, c.body), c.code); got != c.present {
t.Errorf("%.40s %s: present %v, expected %v", c.body, c.code, got, c.present)
}
}
})
t.Run("Lengths are counted in runes", func(t *testing.T) {
e := newSeoOutEnv(t)
if hasCode(e.checkCodes(t, jsonBody(t, "seo_title", repeated("é", 60))), "title_long") {
t.Error("title_long fired for 60 two-byte characters")
}
})
t.Run("The title range is measured on the meta title, not on the template output", func(t *testing.T) {
e := newSeoOutEnv(t)
e.changeSiteTemplate("Thirty chars of prefix here: %title%")
if hasCode(e.checkCodes(t, jsonBody(t, "seo_title", repeated("t", 40))), "title_long") {
t.Error("title_long fired")
}
})
t.Run("Text length ignores tags", func(t *testing.T) {
e := newSeoOutEnv(t)
markup := strings.Repeat(`<span class="x">`, 25) + strings.Repeat("</span>", 25)
body := "<p>" + strings.Repeat("a", 200) + "</p>" + markup
if !hasCode(e.checkCodes(t, jsonBody(t, "body", body)), "short") {
t.Error("short absent")
}
})
t.Run("A fully prepared article is ready", func(t *testing.T) {
e := newSeoOutEnv(t)
seoOutFullyPrepared(e)
checked := e.check(t, `{}`)
if checked["status"] != "ready" || !reflect.DeepEqual(checked["suggestions"], []any{}) {
t.Errorf("checked = %v", checked)
}
})
}
func TestSeoOutputCheckOverrides(t *testing.T) {
t.Run("Body overrides replace saved values for the check only", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SEOTitle = "Short" })
before := e.stored(12)
if !hasCode(e.checkCodes(t, jsonBody(t, "seo_title", repeated("t", 74))), "title_long") {
t.Error("title_long absent")
}
if !reflect.DeepEqual(e.stored(12), before) {
t.Error("the article changed")
}
})
t.Run("Overrides can also clear a saved value", func(t *testing.T) {
cases := []struct {
name string
saved func(*article.Article)
body string
code string
present bool
}{
{"null cover", func(a *article.Article) { a.CoverImageID = idPointer(5) }, `{"cover_image_id": null}`, "cover_alt", false},
{"alt given", func(a *article.Article) { a.CoverImageID = idPointer(5) }, `{"cover_image_alt": "A field"}`, "cover_alt", false},
{"alt cleared", func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = idPointer(5), "A field" }, `{"cover_image_alt": ""}`, "cover_alt", true},
{"seo title cleared", func(a *article.Article) { a.SEOTitle, a.Title = repeated("t", 74), repeated("u", 20) }, `{"seo_title": ""}`, "title_long", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, c.saved)
if got := hasCode(e.checkCodes(t, c.body), c.code); got != c.present {
t.Errorf("present %v", got)
}
})
}
})
t.Run("A body override feeds the body rules and the description fallback", func(t *testing.T) {
e := newSeoOutEnv(t)
sentence := "Sentence one of exactly seventy characters, padded here to reach the minimum."
codes := e.checkCodes(t, jsonBody(t, "body", "<p>"+sentence+" Two.</p>"))
if hasCode(codes, "desc_short") || !hasCode(codes, "short") || e.stored(12).Body != seoOutBody {
t.Errorf("codes = %v", codes)
}
})
t.Run("A body override with images and headings", func(t *testing.T) {
e := newSeoOutEnv(t)
codes := e.checkCodes(t, jsonBody(t, "body", textBody(1000, `<img src="a">`)))
if !hasCode(codes, "no_h") || !hasCode(codes, "body_alt") {
t.Errorf("codes = %v", codes)
}
})
t.Run("The check is non-persisting", func(t *testing.T) {
e := newSeoOutEnv(t)
before := e.stored(12)
siteBefore := e.sites.sites[siteMainID]
e.check(t, `{"slug": "other", "noindex": true, "excerpt": "x", "body": "<p>x</p>", "seo_dismissed": ["short"]}`)
if !reflect.DeepEqual(e.stored(12), before) || !reflect.DeepEqual(e.sites.sites[siteMainID], siteBefore) || e.counted.writes != 0 {
t.Errorf("writes %d", e.counted.writes)
}
e.noEvent(t)
})
t.Run("Dismissed codes are filtered out", func(t *testing.T) {
e := newSeoOutEnv(t)
seoOutFullyPrepared(e)
e.change(12, func(a *article.Article) {
a.CoverImageID, a.CoverImageAlt = nil, ""
a.Body = textBody(100, `<h2>x</h2>`)
a.SEODismissed = []string{"cover_none", "no_au"}
})
e.articles.credits[12] = nil
if codes := e.checkCodes(t, `{}`); !reflect.DeepEqual(codes, []string{"short"}) {
t.Errorf("codes = %v", codes)
}
})
t.Run("A dismissed override in the body filters the check", func(t *testing.T) {
e := newSeoOutEnv(t)
seoOutFullyPrepared(e)
e.articles.credits[12] = nil
e.change(12, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt, a.Body = nil, "", textBody(1000, "") })
checked := e.check(t, `{"seo_dismissed": ["no_au", "cover_none", "no_h"]}`)
if checked["status"] != "ready" || len(checked["suggestions"].([]any)) != 0 {
t.Errorf("checked = %v", checked)
}
e.change(12, func(a *article.Article) { a.Body = textBody(100, "") })
checked = e.check(t, `{"seo_dismissed": ["short", "no_au", "cover_none", "no_h"]}`)
if checked["status"] != "ready" || len(checked["suggestions"].([]any)) != 0 {
t.Errorf("checked = %v", checked)
}
})
t.Run("Dismissing everything leaves ready, noindex still hides", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.Noindex = true })
all := `["title_long","title_empty","desc_short","desc_long","cover_alt","cover_none","slug_long","no_au","no_h","body_alt","short"]`
checked := e.check(t, `{"seo_dismissed": `+all+`}`)
if checked["status"] != "hidden" || len(checked["suggestions"].([]any)) != 0 {
t.Errorf("checked = %v", checked)
}
})
t.Run("The status", func(t *testing.T) {
cases := []struct {
name string
prepare func(*seoOutEnv)
noindex bool
status string
}{
{"ready", seoOutFullyPrepared, false, "ready"},
{"suggestions", func(e *seoOutEnv) {}, false, "suggestions"},
{"hidden without suggestions", seoOutFullyPrepared, true, "hidden"},
{"hidden with suggestions", func(e *seoOutEnv) {}, true, "hidden"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
c.prepare(e)
e.change(12, func(a *article.Article) { a.Noindex = c.noindex })
if got := e.check(t, `{}`)["status"]; got != c.status {
t.Errorf("status %v", got)
}
})
}
})
t.Run("A noindex override gives hidden without saving", func(t *testing.T) {
e := newSeoOutEnv(t)
if e.check(t, `{"noindex": true}`)["status"] != "hidden" || e.stored(12).Noindex {
t.Error("unexpected")
}
})
t.Run("Suggestions come in a fixed order", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) {
a.SEOTitle = repeated("t", 70)
a.CoverImageID, a.CoverImageAlt = idPointer(5), "ok"
a.SEODescription = repeated("d", 100)
})
if codes := e.checkCodes(t, `{}`); !reflect.DeepEqual(codes, []string{"title_long", "no_au", "short"}) {
t.Errorf("codes = %v", codes)
}
})
}
func TestSeoOutputCheckSlugAndValidation(t *testing.T) {
slugOf := func(checked map[string]any) map[string]any { return nested(checked, "slug") }
seed := func(e *seoOutEnv) {
e.seedArticle(14, "Spring", siteMainID, "spring")
e.seedArticle(15, "Spring two", siteMainID, "spring-2")
e.seedArticle(16, "Autumn", seoOutJournalID, "autumn")
}
t.Run("Slug availability on the article's site", func(t *testing.T) {
cases := []struct {
body string
available bool
suggestion any
}{
{`{}`, true, nil},
{`{"slug": "how-we-price-a-bathroom-renovation"}`, true, nil},
{`{"slug": "fresh"}`, true, nil},
{`{"slug": "autumn"}`, true, nil},
{`{"slug": "spring"}`, false, "spring-3"},
{`{"slug": ""}`, true, nil},
}
for _, c := range cases {
e := newSeoOutEnv(t)
seed(e)
got := slugOf(e.check(t, c.body))
if got["available"] != c.available || got["suggestion"] != c.suggestion {
t.Errorf("%s: %v", c.body, got)
}
}
})
t.Run("A regenerated empty slug can be taken", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.Slug = "custom" })
e.seedArticle(14, "Other", siteMainID, seoOutArticleSlug)
got := slugOf(e.check(t, `{"slug": ""}`))
if got["available"] != false || got["suggestion"] != seoOutArticleSlug+"-2" {
t.Errorf("slug = %v", got)
}
})
t.Run("The suggestion fits the slug limit", func(t *testing.T) {
e := newSeoOutEnv(t)
long := repeated("a", 100)
e.seedArticle(14, "Long", siteMainID, long)
got := slugOf(e.check(t, jsonBody(t, "slug", long)))
suggestion, _ := got["suggestion"].(string)
if len(suggestion) > 100 || !strings.HasSuffix(suggestion, "-2") {
t.Errorf("slug = %v", got)
}
})
t.Run("A taken slug is not a refusal", func(t *testing.T) {
e := newSeoOutEnv(t)
seed(e)
if rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", `{"slug": "spring"}`); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The same validation as PATCH applies", func(t *testing.T) {
cases := []struct {
name, body string
status int
text string
}{
{"seo_title", jsonBody(t, "seo_title", repeated("a", 121)), 400, "validator"},
{"seo_description", jsonBody(t, "seo_description", repeated("a", 321)), 400, "validator"},
{"excerpt", jsonBody(t, "excerpt", repeated("a", 301)), 400, "validator"},
{"cover_image_alt", jsonBody(t, "cover_image_alt", repeated("a", 201)), 400, "validator"},
{"social_title", jsonBody(t, "social_title", repeated("a", 121)), 400, "validator"},
{"social_description", jsonBody(t, "social_description", repeated("a", 321)), 400, "validator"},
{"slug uppercase", `{"slug": "Spring"}`, 400, apperr.InvalidSlugErrorMessage},
{"slug long", jsonBody(t, "slug", repeated("a", 101)), 400, apperr.InvalidSlugErrorMessage},
{"canonical", `{"canonical_url": "http://example.com/a"}`, 422, apperr.InvalidURLErrorMessage},
{"cover missing", `{"cover_image_id": 999}`, 400, apperr.ArticleImageNotFoundErrorMessage},
{"social missing", `{"social_image_id": 999}`, 400, apperr.ArticleImageNotFoundErrorMessage},
{"dismissed", `{"seo_dismissed": ["nope"]}`, 400, "validator"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", c.body)
if c.text == "validator" {
expectStatusAndValidatorText(t, rec, c.status)
return
}
expectStatusAndText(t, rec, c.status, c.text)
})
}
})
t.Run("Malformed bodies are refused", func(t *testing.T) {
for _, body := range []string{`{not json`, `{"slug": null}`, `{"noindex": null}`, `{"seo_dismissed": null}`, `{"body": null}`, `{"noindex": "yes"}`, `{"colour": "x"}`, `null`, `{"site_id": 2}`, `{"author_ids": [2]}`} {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", body), 400, apperr.BodyErrorMessage)
}
})
t.Run("Anyone who can edit the article may check it", func(t *testing.T) {
cases := []struct {
who string
status int
}{{"owner", 200}, {"admin", 200}, {"editor", 200}, {"key", 200}, {"unverified", 403}, {"", 401}}
for _, c := range cases {
e := newSeoOutEnv(t)
if rec := e.call(t, c.who, http.MethodPost, "/v1/article/12/seo/check", `{}`); rec.Code != c.status {
t.Errorf("%q: status %d", c.who, rec.Code)
}
}
})
t.Run("A draft can be checked", func(t *testing.T) {
e := newSeoOutEnv(t)
if rec := e.call(t, "owner", http.MethodPost, "/v1/article/13/seo/check", `{}`); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A missing article is not found", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodPost, "/v1/article/999/seo/check", `{}`), 404, apperr.ArticleNotFoundErrorMessage)
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodPost, "/v1/article/abc/seo/check", `{}`), 400, apperr.IdUint32ErrorMessage)
})
t.Run("Publishing is never blocked by suggestions", func(t *testing.T) {
e := newSeoOutEnv(t)
if len(e.check(t, `{}`)["suggestions"].([]any)) == 0 {
t.Fatal("expected suggestions")
}
if rec := e.call(t, "owner", http.MethodPatch, "/v1/article/13/publish", ""); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
}
+84
View File
@@ -0,0 +1,84 @@
package test
import (
articlehandler "RenewCMS/api/handlers/article"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"math"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func TestSeoOutputListPagingNeverPanics(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
for id := uint32(200); id < 207; id++ {
e.seedListed(id, "Listed "+strconv.Itoa(int(id)), true, nil)
}
run := func(page int, size int) useCases.ArticlePage {
t.Helper()
result, err := e.container.ListArticlesUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Verified: true, Page: page, Size: size, SEO: article.SEOStatusReady, Sort: article.Sort{Field: "title", Direction: "asc"},
})
if err != nil {
t.Fatalf("page %d size %d: %v", page, size, err)
}
return result
}
total := run(1, 100).Total
if total != 7 {
t.Fatalf("ready total = %d, want 7", total)
}
cases := []struct {
page, size, items int
}{
{math.MaxInt, 25, 0},
{math.MaxInt, math.MaxInt, 0},
{1, -5, 0},
{0, -5, 0},
{1, 0, 0},
{2, math.MaxInt, 0},
{1, math.MaxInt, 7},
{0, 5, 5},
{-3, 5, 5},
{2, 5, 2},
{3, 5, 0},
{2, 4, 3},
{3, 4, 0},
{math.MinInt, 5, 5},
}
for _, c := range cases {
result := run(c.page, c.size)
if len(result.Items) != c.items || result.Total != total {
t.Errorf("page %d size %d: items %d total %d, want items %d total %d", c.page, c.size, len(result.Items), result.Total, c.items, total)
}
}
}
func TestSeoOutputContractParity(t *testing.T) {
oneof := "oneof=" + strings.Join(article.DismissCodes(), " ")
max := "max=" + strconv.Itoa(article.MaxCanonicalURLRunes)
for _, subject := range []reflect.Type{reflect.TypeOf(articlehandler.CheckArticleSEOBody{}), reflect.TypeOf(articlehandler.UpdateArticleBody{})} {
dismissed, _ := subject.FieldByName("SEODismissed")
if !strings.HasSuffix(dismissed.Tag.Get("validate"), oneof) {
t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), dismissed.Tag.Get("validate"), oneof)
}
canonical, _ := subject.FieldByName("CanonicalURL")
if !strings.Contains(canonical.Tag.Get("validate"), max) {
t.Errorf("%s canonical_url tag %q lacks %q", subject.Name(), canonical.Tag.Get("validate"), max)
}
}
e := newSeoOutEnv(t)
for _, status := range []string{article.SEOStatusReady, article.SEOStatusSuggestions, article.SEOStatusHidden} {
rec := e.call(t, "owner", http.MethodGet, "/v1/article/?seo="+status, "")
if rec.Code != http.StatusOK {
t.Errorf("seo=%s: %d", status, rec.Code)
}
}
if rec := e.call(t, "owner", http.MethodGet, "/v1/article/?seo=bogus", ""); rec.Code != http.StatusBadRequest {
t.Errorf("seo=bogus: %d", rec.Code)
}
}
+40
View File
@@ -0,0 +1,40 @@
package test
import (
"go/parser"
"go/token"
"path/filepath"
"strings"
"testing"
)
func TestSeoOutputLayering(t *testing.T) {
forbidden := []string{"gorm", "net/http", "go-chi", "persistence", "mailer", "api/"}
files := []string{
"seoBody.go", "seoRules.go", "ResolveArticleSEO.go", "CheckArticleSEO.go", "ListSitemap.go", "ListArticles.go",
}
for _, name := range files {
parsed, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "internal", "infrastructure", "useCases", name), nil, parser.ImportsOnly)
if err != nil {
t.Fatal(err)
}
for _, spec := range parsed.Imports {
for _, word := range forbidden {
if strings.Contains(spec.Path.Value, word) {
t.Errorf("%s imports %s", name, spec.Path.Value)
}
}
}
}
for _, name := range []string{"seoStatus.go", "seoResolved.go"} {
parsed, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "internal", "domain", "article", name), nil, parser.ImportsOnly)
if err != nil {
t.Fatal(err)
}
for _, spec := range parsed.Imports {
if strings.Contains(spec.Path.Value, ".") {
t.Errorf("%s imports %s", name, spec.Path.Value)
}
}
}
}
+234
View File
@@ -0,0 +1,234 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"net/http"
"reflect"
"strconv"
"testing"
"time"
)
func (e *seoOutEnv) cleanArticles() {
delete(e.articles.articles, seoOutArticleID)
delete(e.articles.articles, seoOutDraftID)
}
func (e *seoOutEnv) seedListed(id uint32, title string, online bool, mutate func(*article.Article)) {
e.credit(seoOutAuthorMarta, "Marta Alves", "")
e.articles.credits[seoOutArticleID] = nil
e.articles.credit(id, seoOutAuthorMarta)
e.articles.articles[id] = article.Article{
ID: id, Title: title, Body: textBody(400, "<h2>h</h2>"), IsOnline: online, SiteID: siteMainID, Slug: "slug-" + strconv.Itoa(int(id)),
SEOTitle: repeated("t", 45), SEODescription: repeated("d", 100), CoverImageID: idPointer(5), CoverImageAlt: "alt",
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutCreated.Add(time.Duration(id) * time.Hour),
}
if mutate != nil {
found := e.articles.articles[id]
mutate(&found)
e.articles.articles[id] = found
}
}
func (e *seoOutEnv) seedFourListed() {
e.cleanArticles()
e.seedListed(1, "Ready", true, nil)
e.seedListed(2, "Two", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, ""; a.SEOTitle = repeated("t", 70) })
e.seedListed(3, "Hidden", true, func(a *article.Article) {
a.Noindex = true
a.CoverImageID, a.CoverImageAlt = nil, ""
a.SEOTitle = repeated("t", 70)
})
e.seedListed(4, "Four", true, func(a *article.Article) {
a.CoverImageID, a.CoverImageAlt = nil, ""
a.SEOTitle = repeated("t", 70)
a.Slug = repeated("s", 80)
a.SEODismissed = []string{"slug_long"}
})
}
func (e *seoOutEnv) list(t *testing.T, who string, query string) map[string]any {
t.Helper()
rec := e.call(t, who, http.MethodGet, "/v1/article/"+query, "")
if rec.Code != http.StatusOK {
t.Fatalf("list %s: %d %s", query, rec.Code, rec.Body.String())
}
return decodeSeoObject(t, rec)
}
func itemByID(list map[string]any, id int) map[string]any {
for _, item := range list["items"].([]any) {
if found := item.(map[string]any); int(found["id"].(float64)) == id {
return found
}
}
return nil
}
func TestSeoOutputList(t *testing.T) {
t.Run("Items carry seo_status and seo_suggestions", func(t *testing.T) {
e := newSeoOutEnv(t)
e.seedFourListed()
list := e.list(t, "owner", "")
want := map[int][2]any{1: {"ready", 0.0}, 2: {"suggestions", 2.0}, 3: {"hidden", 2.0}, 4: {"suggestions", 2.0}}
for id, expected := range want {
item := itemByID(list, id)
if item["seo_status"] != expected[0] || item["seo_suggestions"] != expected[1] {
t.Errorf("item %d = %v %v", id, item["seo_status"], item["seo_suggestions"])
}
if _, present := item["seo_dismissed"]; present {
t.Errorf("item %d carries seo_dismissed", id)
}
if _, present := item["excerpt"]; !present {
t.Errorf("item %d has no excerpt", id)
}
}
})
t.Run("The list and the check agree", func(t *testing.T) {
e := newSeoOutEnv(t)
e.seedFourListed()
list := e.list(t, "owner", "")
for id := 1; id <= 4; id++ {
rec := e.call(t, "owner", http.MethodPost, "/v1/article/"+strconv.Itoa(id)+"/seo/check", `{}`)
checked := decodeSeoObject(t, rec)
item := itemByID(list, id)
if checked["status"] != item["seo_status"] || float64(len(checked["suggestions"].([]any))) != item["seo_suggestions"] {
t.Errorf("article %d: list %v/%v, check %v", id, item["seo_status"], item["seo_suggestions"], checked)
}
}
})
t.Run("Anonymous callers see online items with their status", func(t *testing.T) {
e := newSeoOutEnv(t)
e.seedFourListed()
e.seedListed(5, "Draft", false, nil)
list := e.list(t, "", "")
if !reflect.DeepEqual(listIDs(list), []int{4, 3, 2, 1}) || itemByID(list, 2)["seo_status"] != "suggestions" {
t.Errorf("ids = %v", listIDs(list))
}
})
t.Run("The seo filter", func(t *testing.T) {
cases := []struct {
value string
ids []int
total float64
}{{"ready", []int{1}, 1}, {"suggestions", []int{4, 2}, 2}, {"hidden", []int{3}, 1}}
for _, c := range cases {
e := newSeoOutEnv(t)
e.seedFourListed()
list := e.list(t, "owner", "?seo="+c.value)
if !reflect.DeepEqual(listIDs(list), c.ids) || list["total"] != c.total {
t.Errorf("%s: ids %v total %v", c.value, listIDs(list), list["total"])
}
}
})
t.Run("The filter composes with search, status, site, sort and paging", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
for id := uint32(1); id <= 7; id++ {
e.seedListed(id, "Tile "+strconv.Itoa(int(id)), false, func(a *article.Article) { a.SiteID = seoOutJournalID; a.CoverImageID, a.CoverImageAlt = nil, "" })
}
for id := uint32(8); id <= 10; id++ {
e.seedListed(id, "Tile "+strconv.Itoa(int(id)), true, func(a *article.Article) { a.SiteID = seoOutJournalID; a.CoverImageID, a.CoverImageAlt = nil, "" })
}
for id := uint32(11); id <= 14; id++ {
e.seedListed(id, "Tile "+strconv.Itoa(int(id)), false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
}
base := "?seo=suggestions&q=tile&status=draft&site=journal&sort=title:asc&size=5&page="
first, second := e.list(t, "owner", base+"1"), e.list(t, "owner", base+"2")
if first["total"] != 7.0 || !reflect.DeepEqual(listIDs(second), []int{6, 7}) {
t.Errorf("first %v second %v total %v", listIDs(first), listIDs(second), first["total"])
}
for _, id := range listIDs(first) {
for _, other := range listIDs(second) {
if id == other {
t.Errorf("id %d on both pages", id)
}
}
}
})
t.Run("Counts stay unfiltered", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
for id := uint32(1); id <= 6; id++ {
online := id <= 4
e.seedListed(id, "A"+strconv.Itoa(int(id)), online, func(a *article.Article) {
if id%2 == 0 {
a.CoverImageID, a.CoverImageAlt = nil, ""
}
})
}
list := e.list(t, "owner", "?seo=suggestions")
if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0}) || list["total"] != 3.0 {
t.Errorf("list = %v %v", list["counts"], list["total"])
}
})
t.Run("The filter is applied before paging", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
for id := uint32(1); id <= 30; id++ {
e.seedListed(id, "A"+strconv.Itoa(int(id)), true, func(a *article.Article) {
if id <= 12 {
a.CoverImageID, a.CoverImageAlt = nil, ""
}
})
}
list := e.list(t, "owner", "?seo=suggestions&size=5&page=3")
if len(listIDs(list)) != 2 || list["total"] != 12.0 {
t.Errorf("ids %v total %v", listIDs(list), list["total"])
}
})
t.Run("The filter respects visibility", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
e.seedListed(1, "Offline", false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
list := e.list(t, "", "?seo=suggestions")
if len(listIDs(list)) != 0 || list["total"] != 0.0 {
t.Errorf("list = %v", list)
}
})
t.Run("Dismissed codes are never counted in the filter", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
e.seedListed(1, "Dismissed", true, func(a *article.Article) {
a.CoverImageID, a.CoverImageAlt = nil, ""
a.SEODismissed = []string{"cover_none"}
})
if ids := listIDs(e.list(t, "owner", "?seo=suggestions")); len(ids) != 0 {
t.Errorf("ids = %v", ids)
}
})
t.Run("An invalid seo value is refused", func(t *testing.T) {
for _, query := range []string{"seo=maybe", "seo=", "seo=READY", "seo=ready,hidden", "seo=1"} {
e := newSeoOutEnv(t)
e.seedFourListed()
before := e.counted.readCalls()
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/?"+query, ""), 400, apperr.BodyErrorMessage)
if e.counted.readCalls() != before {
t.Errorf("%s: the repository was called", query)
}
}
})
t.Run("Invalid format is still reported first", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/?seo=maybe&format=pdf", ""), 400, apperr.ArticleBodyFormatErrorMessage)
})
t.Run("The existing list behaviour is unchanged without the filter", func(t *testing.T) {
e := newSeoOutEnv(t)
list := e.list(t, "owner", "")
if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0}) {
t.Errorf("list = %v", list)
}
})
}
+369
View File
@@ -0,0 +1,369 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
)
const seoOutImageURL = seoOutBase + "/static/uploadedImages/"
func TestSeoOutputResolver(t *testing.T) {
t.Run("The object has the documented shape", func(t *testing.T) {
e := newSeoOutEnv(t)
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
object := decodeSeoObject(t, rec)
cases := map[string]string{
"": "authors,canonical_url,description,fallbacks,image,json_ld,modified_at,og,published_at,robots,title,twitter",
"og": "description,image,image_alt,locale,site_name,title,type,url",
"twitter": "card,description,image,image_alt,title",
"image": "alt,source,url",
"fallbacks": "canonical,description,image,title",
}
for key, want := range cases {
target := object
if key != "" {
target = nested(object, key)
}
if got := sortedKeys(target); got != want {
t.Errorf("keys of %q = %s, expected %s", key, got, want)
}
}
})
t.Run("A bare article resolves every output through the fallbacks", func(t *testing.T) {
e := newSeoOutEnv(t)
object := e.resolve(t, 12)
title := seoOutArticleTitle + " | Renew"
url := seoOutBase + "/" + seoOutArticleSlug
image := seoOutImageURL + "7.png"
want := map[string]any{
"title": title, "description": "We price by the square metre.", "canonical_url": url, "robots": "index, follow",
"image": map[string]any{"url": image, "alt": "", "source": "site"},
"og": map[string]any{"type": "article", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": "", "url": url, "site_name": "Renew", "locale": "en"},
"twitter": map[string]any{"card": "summary_large_image", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": ""},
"fallbacks": map[string]any{"title": "title", "description": "body", "image": "site", "canonical": "site"},
"published_at": "2026-09-01T08:00:00Z", "modified_at": "2026-09-10T09:30:00Z",
}
for key, expected := range want {
if !reflect.DeepEqual(object[key], expected) {
t.Errorf("%s = %v, expected %v", key, object[key], expected)
}
}
})
t.Run("The title chain and the template", func(t *testing.T) {
cases := []struct{ seoTitle, template, title, source string }{
{"", "%title% | %site%", seoOutArticleTitle + " | Renew", "title"},
{"Bathroom costs", "%title% | %site%", "Bathroom costs | Renew", "seo_title"},
{"Bathroom costs", "%site% - %title%", "Renew - Bathroom costs", "seo_title"},
{"Bathroom costs", "%title%", "Bathroom costs", "seo_title"},
{"Bathroom costs", "News: %title%", "News: Bathroom costs", "seo_title"},
{"Bathroom costs", "", "Bathroom costs | Renew", "seo_title"},
}
for _, c := range cases {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SEOTitle = c.seoTitle })
e.changeSite(siteMainID, func(s *site.Site) { s.TitleTemplate = c.template })
object := e.resolve(t, 12)
if object["title"] != c.title || nested(object, "fallbacks")["title"] != c.source {
t.Errorf("%+v: title %q source %v", c, object["title"], nested(object, "fallbacks")["title"])
}
}
})
t.Run("An empty site_name leaves nothing in place of %site% and the title is trimmed", func(t *testing.T) {
cases := map[string]string{"%title% | %site%": "Bathroom costs |", "%site% %title%": "Bathroom costs", "%title%": "Bathroom costs"}
for template, want := range cases {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SEOTitle = "Bathroom costs" })
e.changeSite(siteMainID, func(s *site.Site) { s.SiteName = ""; s.TitleTemplate = template })
if got := e.resolve(t, 12)["title"]; got != want {
t.Errorf("%q: title %q, expected %q", template, got, want)
}
}
})
t.Run("The description chain", func(t *testing.T) {
cases := []struct{ seoDescription, excerpt, body, fallback, want, source string }{
{"Own words.", "A teaser.", "<p>First one. Second.</p>", "Site.", "Own words.", "seo_description"},
{"", "A teaser.", "<p>First one. Second.</p>", "Site.", "A teaser.", "excerpt"},
{"", "", "<p>First one. Second.</p>", "Site.", "First one.", "body"},
{"", "", "<p>No terminator here</p>", "Site.", "No terminator here", "body"},
{"", "", "<p>Only sentence.</p>", "Site.", "Only sentence.", "body"},
{"", "", "<script>var a = 1. 2;</script><style>p { x: y. }</style><script>b.</script><p>Real text. More.</p>", "Site.", "Real text.", "body"},
{"", "", "<h2>Title</h2><p>What? Yes.</p>", "Site.", "Title What?", "body"},
{"", "", "<p>Fish &amp; chips are 3.5 euros. Cheap.</p>", "Site.", "Fish & chips are 3.5 euros.", "body"},
{"", "", "", "Site.", "Site.", "site"},
{"", "", "", "", "", "none"},
}
for _, c := range cases {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SEODescription, a.Excerpt, a.Body = c.seoDescription, c.excerpt, c.body })
e.changeSite(siteMainID, func(s *site.Site) { s.DefaultDescription = c.fallback })
object := e.resolve(t, 12)
if object["description"] != c.want || nested(object, "fallbacks")["description"] != c.source {
t.Errorf("%+v: description %q source %v", c, object["description"], nested(object, "fallbacks")["description"])
}
}
})
t.Run("The image chain", func(t *testing.T) {
cases := []struct {
social, cover *uint32
alt string
path, imgAlt string
source string
}{
{idPointer(6), idPointer(5), "A field", "6.png", "", "social"},
{nil, idPointer(5), "A field", "5.png", "A field", "cover"},
{nil, nil, "", "7.png", "", "site"},
}
for _, c := range cases {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SocialImageID, a.CoverImageID, a.CoverImageAlt = c.social, c.cover, c.alt })
object := e.resolve(t, 12)
image := nested(object, "image")
if image["url"] != seoOutImageURL+c.path || image["alt"] != c.imgAlt || image["source"] != c.source ||
nested(object, "og")["image"] != image["url"] || nested(object, "twitter")["image"] != image["url"] || nested(object, "fallbacks")["image"] != c.source {
t.Errorf("%+v: %v", c, object)
}
}
})
t.Run("No image anywhere", func(t *testing.T) {
e := newSeoOutEnv(t)
e.changeSite(siteMainID, func(s *site.Site) { s.DefaultSocialImageID = nil })
object := e.resolve(t, 12)
if !reflect.DeepEqual(object["image"], map[string]any{"url": "", "alt": "", "source": "none"}) || nested(object, "og")["image"] != "" ||
nested(object, "twitter")["image"] != "" || nested(object, "twitter")["card"] != "summary" || nested(object, "fallbacks")["image"] != "none" {
t.Errorf("object = %v", object)
}
})
t.Run("A dangling image id falls through to the next source", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SocialImageID = idPointer(999) })
if source := nested(e.resolve(t, 12), "image")["source"]; source != "site" {
t.Errorf("source = %v", source)
}
})
t.Run("Canonical and robots", func(t *testing.T) {
own := seoOutBase + "/" + seoOutArticleSlug
cases := []struct {
canonical string
noindex bool
robots string
resolved string
source string
result string
}{
{"", false, "index", own, "site", "index, follow"},
{"", false, "noindex", own, "site", "noindex, nofollow"},
{"https://other.example/a", false, "index", "https://other.example/a", "canonical_url", "index, follow"},
{"", true, "index", own, "site", "noindex, nofollow"},
}
for _, c := range cases {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.CanonicalURL, a.Noindex = c.canonical, c.noindex })
e.changeSite(siteMainID, func(s *site.Site) { s.DefaultRobots = c.robots })
object := e.resolve(t, 12)
if object["canonical_url"] != c.resolved || nested(object, "og")["url"] != c.resolved || nested(object, "fallbacks")["canonical"] != c.source || object["robots"] != c.result {
t.Errorf("%+v: %v", c, object)
}
}
})
t.Run("Social overrides apply to og and twitter only", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) {
a.SocialTitle, a.SocialDescription, a.SEOTitle, a.SEODescription = "Shared title", "Shared text", "Meta title", "Meta text"
})
object := e.resolve(t, 12)
og, twitter := nested(object, "og"), nested(object, "twitter")
if object["title"] != "Meta title | Renew" || object["description"] != "Meta text" ||
og["title"] != "Shared title" || twitter["title"] != "Shared title" || og["description"] != "Shared text" || twitter["description"] != "Shared text" {
t.Errorf("object = %v", object)
}
})
t.Run("Social fields fall back to the resolved values independently", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SocialTitle = "Shared title" })
object := e.resolve(t, 12)
if nested(object, "og")["title"] != "Shared title" || nested(object, "og")["description"] != object["description"] {
t.Errorf("og = %v", nested(object, "og"))
}
})
t.Run("Authors carry the profile URL and only the social links in same_as", func(t *testing.T) {
e := newSeoOutEnv(t)
e.credit(seoOutAuthorMarta, "Marta Alves", "https://marta.example", author.SocialLink{Type: "github", URL: "https://github.com/marta"}, author.SocialLink{Type: "x", URL: "https://x.com/marta"})
e.credit(seoOutAuthorZoe, "Zoe Lambert", "")
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "")
var decoded struct {
Authors []map[string]any `json:"authors"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatal(err)
}
want := []map[string]any{
{"name": "Marta Alves", "url": "https://marta.example", "same_as": []any{"https://github.com/marta", "https://x.com/marta"}},
{"name": "Zoe Lambert", "url": "", "same_as": []any{}},
}
if !reflect.DeepEqual(decoded.Authors, want) {
t.Errorf("authors = %v", decoded.Authors)
}
})
t.Run("An article without authors has an empty authors array", func(t *testing.T) {
e := newSeoOutEnv(t)
object := e.resolve(t, 12)
if !reflect.DeepEqual(object["authors"], []any{}) {
t.Errorf("authors = %v", object["authors"])
}
if _, present := nested(object, "json_ld")["author"]; present {
t.Error("json_ld has an author key")
}
})
t.Run("json_ld is a ready Article object", func(t *testing.T) {
e := newSeoOutEnv(t)
e.credit(seoOutAuthorMarta, "Marta Alves", "https://marta.example", author.SocialLink{Type: "github", URL: "https://github.com/marta"}, author.SocialLink{Type: "x", URL: "https://x.com/marta"})
e.change(12, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = idPointer(5), "A field" })
want := `{"@context": "https://schema.org", "@type": "Article", "headline": "How we price a bathroom renovation", "description": "We price by the square metre.", "image": ["https://www.renew.example/static/uploadedImages/5.png"], "author": [{"@type": "Person", "name": "Marta Alves", "url": "https://marta.example", "sameAs": ["https://github.com/marta", "https://x.com/marta"]}], "datePublished": "2026-09-01T08:00:00Z", "dateModified": "2026-09-10T09:30:00Z", "mainEntityOfPage": {"@type": "WebPage", "@id": "https://www.renew.example/how-we-price-a-bathroom-renovation"}}`
var expected any
if err := json.Unmarshal([]byte(want), &expected); err != nil {
t.Fatal(err)
}
if got := e.resolve(t, 12)["json_ld"]; !reflect.DeepEqual(got, expected) {
t.Errorf("json_ld = %v", got)
}
})
t.Run("The headline uses the meta title without the site template", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SEOTitle = "Bathroom costs" })
object := e.resolve(t, 12)
if nested(object, "json_ld")["headline"] != "Bathroom costs" || object["title"] != "Bathroom costs | Renew" {
t.Errorf("object = %v", object)
}
})
t.Run("The object never carries the body", func(t *testing.T) {
e := newSeoOutEnv(t)
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "")
if strings.Contains(rec.Body.String(), "<p>") || strings.Contains(rec.Body.String(), "Tiles cost extra") {
t.Errorf("body leaked: %s", rec.Body.String())
}
})
t.Run("No format parameter is honoured", func(t *testing.T) {
e := newSeoOutEnv(t)
bare := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "").Body.String()
for _, query := range []string{"?format=html", "?format=json", "?format=pdf"} {
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo"+query, "")
if rec.Code != http.StatusOK || rec.Body.String() != bare {
t.Errorf("%s: %d", query, rec.Code)
}
}
})
t.Run("Another site uses its own base URL, template and robots", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SiteID = seoOutJournalID })
object := e.resolve(t, 12)
if !strings.HasPrefix(object["canonical_url"].(string), "https://journal.renew.example/") || object["robots"] != "noindex, nofollow" || object["title"] != seoOutArticleTitle {
t.Errorf("object = %v", object)
}
})
}
func TestSeoOutputResolverVisibility(t *testing.T) {
t.Run("An online article is public", func(t *testing.T) {
e := newSeoOutEnv(t)
if rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", ""); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A draft is visible to verified callers only", func(t *testing.T) {
cases := []struct {
who string
status int
}{
{"owner", 200}, {"admin", 200}, {"editor", 200}, {"key", 200}, {"", 404}, {"unverified", 404}, {"malformed", 404},
}
for _, c := range cases {
t.Run(c.who, func(t *testing.T) {
e := newSeoOutEnv(t)
rec := e.call(t, c.who, http.MethodGet, "/v1/article/13/seo", "")
if rec.Code != c.status {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if c.status == 404 {
expectStatusAndText(t, rec, 404, apperr.ArticleNotFoundErrorMessage)
}
})
}
})
t.Run("A draft has no publication date", func(t *testing.T) {
e := newSeoOutEnv(t)
rec := e.call(t, "owner", http.MethodGet, "/v1/article/13/seo", "")
object := decodeSeoObject(t, rec)
if object["published_at"] != nil || object["modified_at"] != "2026-09-10T09:30:00Z" {
t.Errorf("object = %v", object)
}
if _, present := nested(object, "json_ld")["datePublished"]; present {
t.Error("json_ld has datePublished")
}
})
t.Run("Unpublishing hides the object from the public again", func(t *testing.T) {
e := newSeoOutEnv(t)
if rec := e.call(t, "owner", http.MethodPatch, "/v1/article/12/unpublish", ""); rec.Code != http.StatusOK {
t.Fatalf("unpublish %d", rec.Code)
}
expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/article/12/seo", ""), 404, apperr.ArticleNotFoundErrorMessage)
})
t.Run("A missing article is not found", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/999/seo", ""), 404, apperr.ArticleNotFoundErrorMessage)
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/abc/seo", ""), 400, apperr.IdUint32ErrorMessage)
})
t.Run("The read is idempotent and writes nothing", func(t *testing.T) {
e := newSeoOutEnv(t)
before := e.stored(12)
first := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "").Body.String()
second := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "").Body.String()
if first != second || e.counted.writes != 0 || !reflect.DeepEqual(e.stored(12), before) {
t.Errorf("writes %d", e.counted.writes)
}
e.noEvent(t)
})
t.Run("A repository failure answers 500", func(t *testing.T) {
e := newSeoOutEnv(t)
e.articles.fail.failNext("Get")
if rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", ""); rec.Code != http.StatusInternalServerError {
t.Errorf("status %d", rec.Code)
}
})
}
+191
View File
@@ -0,0 +1,191 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
func (e *seoOutEnv) sitemap(t *testing.T, who string, query string) (*httptest.ResponseRecorder, []map[string]any) {
t.Helper()
rec := e.call(t, who, http.MethodGet, "/v1/site/renew/sitemap"+query, "")
var items []map[string]any
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
}
return rec, items
}
func (e *seoOutEnv) seedOnline(count int) {
e.cleanArticles()
for id := 1; id <= count; id++ {
e.articles.articles[uint32(id)] = article.Article{
ID: uint32(id), Title: "Article " + strconv.Itoa(id), IsOnline: true, SiteID: siteMainID, Slug: "slug-" + strconv.Itoa(id),
CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
}
}
}
func TestSeoOutputSitemap(t *testing.T) {
t.Run("The sitemap lists the online articles of one site", func(t *testing.T) {
e := newSeoOutEnv(t)
e.articles.articles[16] = article.Article{ID: 16, Title: "Sixteen", IsOnline: true, SiteID: siteMainID, Slug: "sixteen", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
e.articles.articles[20] = article.Article{ID: 20, Title: "Twenty", IsOnline: true, SiteID: seoOutJournalID, Slug: "twenty", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
rec, items := e.sitemap(t, "", "")
want := []map[string]any{
{"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
{"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
}
if rec.Code != http.StatusOK || !reflect.DeepEqual(items, want) || rec.Header().Get("X-Total-Count") != "2" {
t.Errorf("status %d total %q items %v", rec.Code, rec.Header().Get("X-Total-Count"), items)
}
})
t.Run("Noindex articles are included with the flag", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.Noindex = true })
_, items := e.sitemap(t, "", "")
if len(items) != 1 || items[0]["noindex"] != true {
t.Errorf("items = %v", items)
}
})
t.Run("The url uses the slug and not the canonical override", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.CanonicalURL = "https://other.example/a" })
_, items := e.sitemap(t, "", "")
if items[0]["url"] != seoOutBase+"/"+seoOutArticleSlug {
t.Errorf("items = %v", items)
}
})
t.Run("Drafts are never listed", func(t *testing.T) {
e := newSeoOutEnv(t)
rec, items := e.sitemap(t, "owner", "")
if rec.Header().Get("X-Total-Count") != "1" || len(items) != 1 {
t.Errorf("items = %v", items)
}
})
t.Run("An unpublished article leaves the sitemap", func(t *testing.T) {
e := newSeoOutEnv(t)
e.call(t, "owner", http.MethodPatch, "/v1/article/12/unpublish", "")
rec, items := e.sitemap(t, "", "")
if rec.Header().Get("X-Total-Count") != "0" || len(items) != 0 {
t.Errorf("items = %v", items)
}
})
t.Run("An empty site answers an empty array", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.IsOnline = false })
rec, _ := e.sitemap(t, "", "")
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" {
t.Errorf("%d %q", rec.Code, rec.Body.String())
}
})
t.Run("Each item has exactly the documented keys", func(t *testing.T) {
e := newSeoOutEnv(t)
_, items := e.sitemap(t, "", "")
if sortedKeys(items[0]) != "modified_at,noindex,url" {
t.Errorf("keys = %s", sortedKeys(items[0]))
}
})
t.Run("Paging and the total header", func(t *testing.T) {
cases := []struct {
query string
count int
}{{"", 100}, {"?size=25", 25}, {"?page=3", 50}, {"?page=2&size=100", 100}, {"?size=500", 250}, {"?page=99&size=25", 0}}
e := newSeoOutEnv(t)
e.seedOnline(250)
for _, c := range cases {
rec, items := e.sitemap(t, "", c.query)
if len(items) != c.count || rec.Header().Get("X-Total-Count") != "250" {
t.Errorf("%q: %d items, total %q", c.query, len(items), rec.Header().Get("X-Total-Count"))
}
}
})
t.Run("Pages partition the sitemap in a stable order", func(t *testing.T) {
e := newSeoOutEnv(t)
e.seedOnline(60)
seen := []string{}
for page, want := range map[int]int{1: 25, 2: 25, 3: 10} {
_, items := e.sitemap(t, "", "?size=25&page="+strconv.Itoa(page))
if len(items) != want {
t.Errorf("page %d holds %d", page, len(items))
}
for index, item := range items {
id := (page-1)*25 + index + 1
if item["url"] != seoOutBase+"/slug-"+strconv.Itoa(id) {
t.Errorf("page %d item %d = %v", page, index, item["url"])
}
seen = append(seen, item["url"].(string))
}
}
if len(seen) != 60 {
t.Errorf("seen %d", len(seen))
}
})
t.Run("Invalid paging answers 400", func(t *testing.T) {
for _, query := range []string{"page=0", "page=-1", "page=abc", "page=", "size=0", "size=10", "size=1000", "size=abc"} {
e := newSeoOutEnv(t)
before := e.counted.readCalls()
expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/site/renew/sitemap?"+query, ""), 400, apperr.BodyErrorMessage)
if e.counted.readCalls() != before {
t.Errorf("%s: the repository was called", query)
}
}
})
t.Run("An unknown key is not found", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/site/nope/sitemap", ""), 404, apperr.SiteNotFoundErrorMessage)
})
t.Run("The key is not an id", func(t *testing.T) {
e := newSeoOutEnv(t)
if rec := e.call(t, "", http.MethodGet, "/v1/site/1/sitemap", ""); rec.Code != http.StatusNotFound {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The route does not disturb the owner-only site routes", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "admin", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.OwnerRequiredErrorMessage)
})
t.Run("The browser can read the total header", func(t *testing.T) {
e := newSeoOutEnv(t)
settings := e.settings
settings.CorsAllowedOrigins = []string{"https://app.example"}
req := httptest.NewRequest(http.MethodGet, "/v1/site/renew/sitemap", nil)
req.Header.Set("Origin", "https://app.example")
rec := httptest.NewRecorder()
api.InitRoutes(e.container, settings).ServeHTTP(rec, req)
exposed := rec.Header().Get("Access-Control-Expose-Headers")
if rec.Code != http.StatusOK || !strings.Contains(exposed, "X-Total-Count") || !strings.Contains(exposed, "Link") {
t.Errorf("status %d exposed %q", rec.Code, exposed)
}
})
t.Run("A repository failure answers 500", func(t *testing.T) {
e := newSeoOutEnv(t)
e.articles.fail.failNext("FindPage")
if rec := e.call(t, "", http.MethodGet, "/v1/site/renew/sitemap", ""); rec.Code != http.StatusInternalServerError {
t.Errorf("status %d", rec.Code)
}
})
}
+271
View File
@@ -0,0 +1,271 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"RenewCMS/api"
)
const (
seoOutArticleID = 12
seoOutDraftID = 13
seoOutArticleTitle = "How we price a bathroom renovation"
seoOutArticleSlug = "how-we-price-a-bathroom-renovation"
seoOutBody = "<p>We price by the square metre. Tiles cost extra.</p>"
seoOutBase = "https://www.renew.example"
seoOutSiteImage = 7
seoOutJournalID = 2
seoOutAuthorMarta = 2
seoOutAuthorZoe = 3
)
var (
seoOutCreated = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
seoOutModified = time.Date(2026, 9, 10, 9, 30, 0, 0, time.UTC)
)
type mockSeoOutArticleRepo struct {
*mockArticleRepo
reads map[string]int
writes int
}
func newMockSeoOutArticleRepo(inner *mockArticleRepo) *mockSeoOutArticleRepo {
return &mockSeoOutArticleRepo{mockArticleRepo: inner, reads: map[string]int{}}
}
func (m *mockSeoOutArticleRepo) readCalls() int {
total := 0
for _, count := range m.reads {
total += count
}
return total
}
func (m *mockSeoOutArticleRepo) Get(id uint32) (article.Article, error) {
m.reads["Get"]++
return m.mockArticleRepo.Get(id)
}
func (m *mockSeoOutArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) {
m.reads["FindPage"]++
return m.mockArticleRepo.FindPage(filters)
}
func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) {
m.reads["CountByVisibility"]++
return m.mockArticleRepo.CountByVisibility(onlineOnly)
}
func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
m.reads["SlugTaken"]++
return m.mockArticleRepo.SlugTaken(siteID, slug, exceptID)
}
func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) {
m.writes++
return m.mockArticleRepo.Create(a)
}
func (m *mockSeoOutArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
m.writes++
return m.mockArticleRepo.UpdateBody(id, body)
}
func (m *mockSeoOutArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
m.writes++
return m.mockArticleRepo.Update(id, update)
}
func (m *mockSeoOutArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
m.writes++
return m.mockArticleRepo.UpdateIsOnline(id, isOnline)
}
func (m *mockSeoOutArticleRepo) Delete(id uint32) error {
m.writes++
return m.mockArticleRepo.Delete(id)
}
var _ article.Repository = &mockSeoOutArticleRepo{}
type seoOutEnv struct {
*seoEnv
counted *mockSeoOutArticleRepo
}
func newSeoOutEnv(t *testing.T) *seoOutEnv {
t.Helper()
e := newSeoEnv(t)
counted := newMockSeoOutArticleRepo(e.articles)
locale := "en"
renew := e.sites.sites[siteMainID]
renew.Key = "renew"
renew.BaseURL = seoOutBase
renew.SiteName = "Renew"
renew.TitleTemplate = "%title% | %site%"
renew.DefaultDescription = "Renovation notes from the Renew team."
renew.DefaultRobots = "index"
renew.Locale = &locale
renew.DefaultSocialImageID = idPointer(seoOutSiteImage)
e.sites.sites[siteMainID] = renew
journal := e.sites.sites[seoOutJournalID]
journal.Key = "journal"
journal.BaseURL = "https://journal.renew.example"
journal.TitleTemplate = "%title%"
journal.DefaultRobots = "noindex"
e.sites.sites[seoOutJournalID] = journal
e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
c := e.container
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images)
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
e.router = router
e.articles.articles[seoOutArticleID] = article.Article{
ID: seoOutArticleID, Title: seoOutArticleTitle, Body: seoOutBody, IsOnline: true, SiteID: siteMainID, Slug: seoOutArticleSlug,
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
}
e.articles.articles[seoOutDraftID] = article.Article{
ID: seoOutDraftID, Title: "A draft about tiles", Body: seoOutBody, SiteID: siteMainID, Slug: "a-draft-about-tiles",
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
}
e.articles.nextID = 100
return &seoOutEnv{seoEnv: e, counted: counted}
}
func (e *seoOutEnv) change(id uint32, mutate func(*article.Article)) {
found := e.articles.articles[id]
mutate(&found)
e.articles.articles[id] = found
}
func (e *seoOutEnv) changeSite(id uint32, mutate func(*site.Site)) {
found := e.sites.sites[id]
mutate(&found)
e.sites.sites[id] = found
}
func (e *seoOutEnv) credit(id uint32, name string, website string, links ...author.SocialLink) {
found := e.authors.seed(id, name, strings.ToLower(strings.ReplaceAll(name, " ", "-")))
if website != "" {
found.Website = &website
}
found.SocialLinks = append([]author.SocialLink{}, links...)
e.authors.authors[id] = found
e.articles.credits[seoOutArticleID] = append(e.articles.credits[seoOutArticleID], id)
}
func (e *seoOutEnv) call(t *testing.T, who string, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
switch who {
case "owner":
req.AddCookie(e.cookie(t, apiKeyOwnerId, 0))
case "admin":
req.AddCookie(e.cookie(t, apiKeyPatId, 0))
case "editor":
req.AddCookie(e.cookie(t, seoEditorID, 0))
case "unverified":
req.AddCookie(e.cookie(t, apiKeyQuinnId, 0))
case "malformed":
req.AddCookie(&http.Cookie{Name: "jwt", Value: "not-a-token"})
case "key":
raw, _ := e.issueKey(apiKeyDevId, "seo")
req.Header.Set("Authorization", "Bearer "+raw)
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func (e *seoOutEnv) resolve(t *testing.T, id int) map[string]any {
t.Helper()
rec := e.call(t, "", http.MethodGet, "/v1/article/"+strconv.Itoa(id)+"/seo", "")
if rec.Code != http.StatusOK {
t.Fatalf("resolve %d: %d %s", id, rec.Code, rec.Body.String())
}
return decodeSeoObject(t, rec)
}
func (e *seoOutEnv) check(t *testing.T, body string) map[string]any {
t.Helper()
rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", body)
if rec.Code != http.StatusOK {
t.Fatalf("check %s: %d %s", body, rec.Code, rec.Body.String())
}
return decodeSeoObject(t, rec)
}
func (e *seoOutEnv) checkCodes(t *testing.T, body string) []string {
t.Helper()
return suggestionCodes(e.check(t, body))
}
func suggestionCodes(checked map[string]any) []string {
codes := []string{}
for _, item := range checked["suggestions"].([]any) {
codes = append(codes, item.(map[string]any)["code"].(string))
}
return codes
}
func hasCode(codes []string, code string) bool {
for _, found := range codes {
if found == code {
return true
}
}
return false
}
func sortedKeys(object map[string]any) string {
keys := make([]string, 0, len(object))
for key := range object {
keys = append(keys, key)
}
sort.Strings(keys)
return strings.Join(keys, ",")
}
func nested(object map[string]any, key string) map[string]any {
return object[key].(map[string]any)
}
func textBody(runes int, extra string) string {
return "<p>" + strings.Repeat("a", runes) + "</p>" + extra
}
func jsonBody(t *testing.T, field string, value string) string {
t.Helper()
return `{"` + field + `": ` + jsonString(t, value) + `}`
}
func listIDs(list map[string]any) []int {
ids := []int{}
for _, item := range list["items"].([]any) {
ids = append(ids, int(item.(map[string]any)["id"].(float64)))
}
return ids
}
func (e *seoOutEnv) changeSiteTemplate(template string) {
e.changeSite(siteMainID, func(s *site.Site) { s.TitleTemplate = template })
}
+73 -12
View File
@@ -21,11 +21,13 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/article/{id}/seo',
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
'DELETE /v1/article/{id}',
'POST /v1/article/{id}/seo/check',
],
Media: [
'GET /v1/media/',
@@ -48,7 +50,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/auth/me',
],
Settings: ['GET /v1/settings'],
Sites: ['GET /v1/site/'],
Sites: ['GET /v1/site/', 'GET /v1/site/{key}/sitemap'],
Authors: ['GET /v1/author/'],
}
@@ -102,6 +104,8 @@ const ARTICLE_LIST_KEYS = [
'site_id',
'slug',
'excerpt',
'seo_status',
'seo_suggestions',
'created_at',
'updated_at',
]
@@ -203,13 +207,13 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 26 key-callable routes in 6 groups, in order', () => {
it('holds exactly the 29 key-callable routes in 6 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(26)
expect(allEndpoints()).toHaveLength(29)
})
it('holds the 25 session-only rows in 7 sub-groups', () => {
@@ -238,7 +242,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(6 + 26 + 25 + 6)
expect(described).toHaveLength(6 + 29 + 25 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -266,7 +270,7 @@ describe('Catalogue completeness', () => {
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{id}', String(endpoint.pathId))}"`,
`"${ORIGIN}${endpoint.path.replace('{key}', 'renew-blog').replace('{id}', String(endpoint.pathId))}"`,
)
expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('')
@@ -281,12 +285,17 @@ describe('Catalogue completeness', () => {
},
)
it('gives Public to the two article reads and Owner only to the invite routes', () => {
it('gives Public to the article reads, the resolved SEO and the sitemap, and Owner only to the invite routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Public')
.map(label),
).toEqual(['GET /v1/article/', 'GET /v1/article/{id}'])
).toEqual([
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/article/{id}/seo',
'GET /v1/site/{key}/sitemap',
])
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner only')
@@ -299,6 +308,53 @@ describe('Catalogue completeness', () => {
})
})
describe('SEO output routes', () => {
it('documents the seo list parameter and the two list fields', () => {
expect(find('GET /v1/article/').query.map((field) => field.name)).toContain('seo')
for (const item of parsed(find('GET /v1/article/')).items) {
expect(item).toHaveProperty('seo_status')
expect(item).toHaveProperty('seo_suggestions')
}
})
it('answers the resolved object with its twelve keys and no body', () => {
const resolved = parsed(find('GET /v1/article/{id}/seo'))
expect(keysOf(resolved)).toEqual(
[
'title',
'description',
'canonical_url',
'robots',
'og',
'twitter',
'image',
'authors',
'published_at',
'modified_at',
'fallbacks',
'json_ld',
].sort(),
)
})
it('answers the check with status, suggestions and slug and takes a JSON body', () => {
const checked = parsed(find('POST /v1/article/{id}/seo/check'))
expect(keysOf(checked)).toEqual(['slug', 'status', 'suggestions'])
const names = find('POST /v1/article/{id}/seo/check').body.map((field) => field.name)
expect(names).toContain('body')
expect(names).not.toContain('site_id')
expect(names).not.toContain('author_ids')
})
it('answers the sitemap as an array of url, modified_at and noindex with a page and a size', () => {
const sitemap = find('GET /v1/site/{key}/sitemap')
expect(sitemap.query.map((field) => field.name)).toEqual(['page', 'size'])
for (const item of parsed(sitemap)) {
expect(keysOf(item)).toEqual(['modified_at', 'noindex', 'url'])
}
})
})
describe('Owner or admin purge routes', () => {
it('gives Owner or admin to the four purge routes only', () => {
expect(
@@ -542,13 +598,15 @@ describe('Request bodies and query parameters', () => {
}
})
it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
it('lists format on every article route except the SEO routes', () => {
for (const endpoint of API_GROUPS[0]!.endpoints.filter(
(item) => !item.path.includes('/seo'),
)) {
expect(endpoint.query.map((param) => param.name)).toContain('format')
}
})
it('lists page, size, q, status, sort and site on the article list only', () => {
it('lists page, size, q, status, sort, site and seo on the article list only', () => {
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
'format',
'page',
@@ -557,6 +615,7 @@ describe('Request bodies and query parameters', () => {
'status',
'sort',
'site',
'seo',
])
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
})
@@ -652,8 +711,10 @@ describe('Per-endpoint error captions use the real strings', () => {
expect(find(route).errors).toContainEqual({ status, caption })
})
it('adds the format error to every article card', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
it('adds the format error to every article card except the SEO routes', () => {
for (const endpoint of API_GROUPS[0]!.endpoints.filter(
(item) => !item.path.includes('/seo'),
)) {
expect(endpoint.errors, label(endpoint)).toContainEqual({
status: 400,
caption: 'The format must be one of: markdown, html, json.',
+238 -5
View File
@@ -137,6 +137,108 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
required: false,
rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.',
},
{
name: 'seo',
type: 'string',
required: false,
rule: 'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.',
},
]
const SITEMAP_QUERY: ApiField[] = [
{
name: 'page',
type: 'number',
required: false,
rule: 'A whole number from 1. Default 1.',
},
{
name: 'size',
type: 'number',
required: false,
rule: '25, 50, 100 or 500. Default 100.',
},
]
const CHECK_BODY: ApiField[] = [
{
name: 'body',
type: 'string',
required: false,
rule: 'Replaces the saved body for this check only. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Same rules as PATCH. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image. null means no cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image. null means no social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.',
},
]
const MEDIA_QUERY: ApiField[] = [
@@ -197,6 +299,90 @@ const article = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const SAMPLE_SITE_KEY = 'renew-blog'
const resolvedSeo = () => ({
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
canonical_url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
robots: 'index, follow',
og: {
type: 'article',
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
image_alt: 'A tiled bathroom wall',
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
locale: 'en',
},
twitter: {
card: 'summary_large_image',
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
image_alt: 'A tiled bathroom wall',
},
image: {
url: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
alt: 'A tiled bathroom wall',
source: 'cover',
},
authors: [
{
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
same_as: ['https://github.com/ines-duarte'],
},
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-09-10T07:00:00Z',
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: ARTICLE_TITLE,
description: 'What drives the price of a bathroom renovation.',
image: ['https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg'],
author: [
{
'@type': 'Person',
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
sameAs: ['https://github.com/ines-duarte'],
},
],
datePublished: '2026-03-12T09:14:00Z',
dateModified: '2026-09-10T07:00:00Z',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
},
},
})
const seoCheck = () => ({
status: 'suggestions',
suggestions: [
{ code: 'desc_short', field: 'seo_description', params: { length: 49, min: 70 } },
{ code: 'no_au', field: 'author_ids', params: {} },
],
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
})
const sitemapPage = () => [
{
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
modified_at: '2026-09-10T07:00:00Z',
noindex: false,
},
{
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
modified_at: '2026-09-09T07:00:00Z',
noindex: true,
},
]
const LIST_ITEM_OMITTED_KEYS = [
'seo_title',
'seo_description',
@@ -210,10 +396,13 @@ const LIST_ITEM_OMITTED_KEYS = [
'seo_dismissed',
]
const articleListItem = (overrides: Record<string, unknown> = {}) =>
Object.fromEntries(
const articleListItem = (overrides: Record<string, unknown> = {}) => ({
...Object.fromEntries(
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
)
),
seo_status: 'suggestions',
seo_suggestions: 2,
})
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
@@ -305,6 +494,8 @@ const articlePage = () => ({
slug: 'choosing-tiles-for-a-small-bathroom',
excerpt: '',
updated_at: '2026-09-09T07:00:00Z',
seo_status: 'ready',
seo_suggestions: 0,
}),
],
total: 30,
@@ -405,6 +596,15 @@ export const API_GROUPS: ApiGroup[] = [
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-seo',
'GET',
'/v1/article/{id}/seo',
'Get the resolved SEO object of an article: title, description, canonical address, robots, Open Graph, Twitter, image, authors, dates and ready JSON-LD. Drafts answer 404 unless the caller is verified.',
'Public',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
json(resolvedSeo()),
),
endpoint(
'art-create',
'POST',
@@ -597,6 +797,28 @@ export const API_GROUPS: ApiGroup[] = [
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-check',
'POST',
'/v1/article/{id}/seo/check',
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.',
'Verified user',
verified([
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Article not found'),
e(422, 'invalid_url'),
]),
json(seoCheck()),
{
bodyKind: 'json',
body: CHECK_BODY,
bodySample: { seo_title: 'Bathroom costs, explained', noindex: false },
},
),
],
},
{
@@ -977,7 +1199,7 @@ export const API_GROUPS: ApiGroup[] = [
key: 'sites',
title: 'Sites',
description:
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.',
endpoints: [
endpoint(
'site-list',
@@ -1004,6 +1226,16 @@ export const API_GROUPS: ApiGroup[] = [
}),
]),
),
endpoint(
'site-sitemap',
'GET',
'/v1/site/{key}/sitemap',
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.',
'Public',
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
json(sitemapPage()),
{ query: SITEMAP_QUERY },
),
],
},
{
@@ -1206,7 +1438,8 @@ export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
const keyed = item.path.replace('{key}', SAMPLE_SITE_KEY)
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
}
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {
+8 -2
View File
@@ -1967,8 +1967,14 @@ export const messages = {
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
"You don't have an author profile yet. Create one to be credited on your own articles.":
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.':
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire et tout le monde peut lire un sitemap ; les modifications se font dans le navigateur par le propriétaire.',
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.':
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau. Le total est dans l’en-tête X-Total-Count.',
'The request cannot be processed due to a mismatch in the format of the body.':
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
site_not_found: 'Site introuvable',
'25, 50, 100 or 500. Default 100.': '25, 50, 100 ou 500. Par défaut 100.',
'List the sites, the default first and then by name, each with its article count.':
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles.',
'You must be logged in to access this resource.':
@@ -170,15 +170,15 @@ describe('Default, every card collapsed', () => {
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the 24 cards as 7, 10, 6 and 1', async () => {
it('groups the cards as 9, 10, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([7, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('7 routes')
).toEqual([9, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('9 routes')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})