feat: SEO backend output (2/4)
This commit is contained in:
1 parent
0eb46b0d74
commit
eb9876fde3
32 files changed
+3859
-65
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 & 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user