diff --git a/README.md b/README.md index 539627b..010a754 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51 | [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing | | [docs/configuration.md](docs/configuration.md) | Every environment variable | | [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded | +| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter | | [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log | | [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas | | [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache | diff --git a/api/handlers/author/authorHandler.go b/api/handlers/author/authorHandler.go index 26c5461..c18a9c5 100644 --- a/api/handlers/author/authorHandler.go +++ b/api/handlers/author/authorHandler.go @@ -121,7 +121,7 @@ func readPatchAuthor(r *http.Request) (PatchAuthor, bool) { if err := json.Unmarshal(raw, &fields); err != nil { return PatchAuthor{}, false } - known := 0 + known := false for _, name := range requiredNonNullFields { value, present := fields[name] if !present { @@ -130,14 +130,14 @@ func readPatchAuthor(r *http.Request) (PatchAuthor, bool) { if string(value) == "null" { return PatchAuthor{}, false } - known++ + known = true } for _, name := range nullableFields { if _, present := fields[name]; present { - known++ + known = true } } - if known == 0 { + if !known { return PatchAuthor{}, false } var request PatchAuthor @@ -288,6 +288,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { for _, item := range page.Items { items = append(items, presentAuthor(item)) } + w.Header().Set("X-Total-Count", strconv.Itoa(page.Total)) writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size}) } diff --git a/api/handlers/media/mediaHandler.go b/api/handlers/media/mediaHandler.go index 0ab3a12..96c5c4a 100644 --- a/api/handlers/media/mediaHandler.go +++ b/api/handlers/media/mediaHandler.go @@ -250,6 +250,19 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, response) } +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + id, ok := parseImageId(w, r) + if !ok { + return + } + item, err := h.ListUseCase.Get(id) + if err != nil { + writeImageError(w, err) + return + } + writeJSON(w, http.StatusOK, toResponse(item)) +} + func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { userId, _ := claims.UserId(r) input, closeFile, ok := upload.ReadImageInput(w, r, userId) diff --git a/api/handlers/site/siteHandler.go b/api/handlers/site/siteHandler.go index fb271af..03a19e7 100644 --- a/api/handlers/site/siteHandler.go +++ b/api/handlers/site/siteHandler.go @@ -10,6 +10,7 @@ import ( "errors" "log" "net/http" + "net/url" "strconv" "github.com/go-chi/chi/v5" @@ -135,17 +136,60 @@ func writeSiteError(w http.ResponseWriter, err error) { } } -func (h *Handler) List(w http.ResponseWriter, _ *http.Request) { - sites, err := h.ListUseCase.List() +const ( + defaultListPage = 1 + defaultListSize = 25 +) + +type listQuery struct { + Page int `validate:"gte=1"` + Size int `validate:"omitempty,oneof=5 25 50 100"` + Q string `validate:"max=200"` +} + +func intQueryParam(values url.Values, name string, fallback int) (int, error) { + if !values.Has(name) { + return fallback, nil + } + return strconv.Atoi(values.Get(name)) +} + +func readListQuery(values url.Values) (listQuery, error) { + paged := values.Has("page") || values.Has("size") + page, err := intQueryParam(values, "page", defaultListPage) + if err != nil { + return listQuery{}, err + } + size := 0 + if paged { + size, err = intQueryParam(values, "size", defaultListSize) + if err != nil { + return listQuery{}, err + } + if size == 0 { + return listQuery{}, errors.New("invalid size") + } + } + return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + query, err := readListQuery(r.URL.Query()) + if err != nil || validator.Validate.Struct(query) != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + page, err := h.ListUseCase.ListPage(useCases.ListSitesCommand{Q: query.Q, Page: query.Page, Size: query.Size}) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - body, err := json.Marshal(sites) + body, err := json.Marshal(page.Items) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } + w.Header().Set("X-Total-Count", strconv.Itoa(page.Total)) if _, err := w.Write(body); err != nil { log.Printf("Failed to write response: %v", err) } diff --git a/api/router.go b/api/router.go index 45aa4ed..7925a81 100644 --- a/api/router.go +++ b/api/router.go @@ -350,6 +350,7 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler { r.Post("/merge", mediaHandler.Merge) r.Patch("/{id}", mediaHandler.Rename) r.Delete("/{id}", mediaHandler.Delete) + r.Get("/{id}", mediaHandler.Get) r.Get("/{id}/usage", mediaHandler.Usage) return r } diff --git a/docs/api.md b/docs/api.md index 4aefef6..1310c2a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,11 +22,11 @@ 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 (29) +## Routes a key can call (30) | Group | Routes | | --- | --- | | 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) | +| Media | GET /v1/media/, GET /v1/media/{id}, 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/, GET /v1/site/{key}/sitemap | @@ -115,6 +115,11 @@ with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `so `hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size` (the index rows of the image and their bytes, 0 when none). +`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an +unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with +the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with +the id-format message. + Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit` are no longer parameters; they are ignored, never an error, and have no effect on paging. @@ -132,7 +137,7 @@ session." with a key). The role is checked before the body. | Route | Purpose | | --- | --- | -| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. | +| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. | | `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. | | `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. | | `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. | @@ -238,7 +243,7 @@ article is allowed and never blocks publishing. | Route | Who | Purpose | | --- | --- | --- | -| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`; a bad query is 400 with the body-mismatch message. | +| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. | | `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. | | `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. | | `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. | diff --git a/docs/api/articles.md b/docs/api/articles.md index f810e87..0795a2c 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -25,7 +25,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl "total": 30, "page": 1, "size": 25, - "counts": { "all": 30, "online": 30, "draft": 0 } + "counts": { "all": 30, "online": 30, "draft": 0, "suggestions": 4 } } ``` @@ -42,7 +42,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl - `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`. +- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. 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. diff --git a/docs/development.md b/docs/development.md index c6092ca..48a111b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -72,6 +72,11 @@ task clean # remove bin/, tmp/ and the built SPA - All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network. - HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler. - SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check. +- A few tests open a real SQLite file in a temporary directory (the chunked id lookups, the slug backfill and the `?seo=` measurement). They never touch the real database. +- The tests that need a real MySQL, MariaDB or PostgreSQL server skip themselves when `RENEWCMS_TEST_MYSQL_DSN` or `RENEWCMS_TEST_POSTGRES_DSN` is unset. No CI exists, so run them by hand before a release that touches the repositories: + - `task test:mysql` runs `mysql:8.4` then `mariadb:11` in Docker, and `task test:postgres` runs `postgres:16` then `postgres:17`. Docker is required; the tasks start and remove the containers. Extra arguments go after `--`, for example `task test:postgres -- -run Postgres.*Chunked -v`. + - To use your own server on a scratch database: `RENEWCMS_TEST_MYSQL_DSN='user:pass@tcp(host:3306)/db' go test -tags nofrontend ./test/... -run MySQL -count=1`, or `RENEWCMS_TEST_POSTGRES_DSN='postgres://user:pass@host:5432/db?sslmode=disable' go test -tags nofrontend ./test/... -run Postgres -count=1`. + - `TestMySQLChunkedLookups` and `TestPostgresChunkedLookups` check that the batched `IN` lookups of the article repository (500 ids per batch) return the same rows as SQLite. ## Visual check against the design diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..78be175 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,21 @@ +# Performance notes + +## The `?seo=` article filter + +`GET /v1/article/` assesses the SEO status of articles in memory, because the status depends on the body, the site template and the authors. Every request assesses every visible article once, to fill `counts.suggestions`, and `?seo=` reuses that assessment to filter before paging. The cost therefore grows with the number of articles, not with the page size. + +Measured by `TestSeoFilterPerformance` in `test/seo_filter_performance_test.go` (skipped with `-short`): 5,000 articles of about 5 KB each in one site, on a SQLite file in a temporary directory, every article with suggestions, `?seo=suggestions&size=25`, slowest of 3 runs: + +| Version | Time | +| --- | --- | +| `counts.suggestions` added, assessment done twice | about 430 ms | +| assessment shared between the counts and the filter | about 270 ms | + +The target is 300 ms for 5,000 articles. The test also measures the default list (`GET /v1/article/` with no `?seo=`), which pays the same assessment for `counts.suggestions`. Latest run on the development machine, slowest of 3 runs: + +| Request | Time | +| --- | --- | +| `?seo=suggestions&size=25` | 315 to 338 ms | +| default page, no `?seo=` | 307 to 329 ms | + +Both are slightly above the 300 ms target on this machine (they were about 270 ms in the earlier measurement), so the target is logged by the test but not enforced; no cache or index was added. Re-run the test with `go test -tags nofrontend ./test/... -run TestSeoFilterPerformance -v -count=1` after a change to the assessment rules or the article repository. The time depends on the machine; the test only fails above 500 ms. diff --git a/docs/release-notes.md b/docs/release-notes.md index adab14f..eaf6537 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -41,11 +41,16 @@ Articles and sites now store the data a website needs for search and sharing, an - `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. +- `GET /v1/article/` counts gain `suggestions`, the number of visible articles that still have SEO suggestions. +- `GET /v1/site/` takes `q`, `page` and `size` and sends the number of matching sites in `X-Total-Count`; without `page` and `size` every site is still returned, as a JSON array. +- `GET /v1/author/` also sends its total in `X-Total-Count`. +- `GET /v1/media/{id}` reads one image, with the fields of a media list item; a missing image answers `404` and a malformed id `400`. ### Upgrading - The first start after the upgrade adds the new columns, gives every existing article a slug generated from its title (in id order within each site, with `-2`, `-3` on conflicts) and creates the unique `(site_id, slug)` index. Nothing else changes, and a second start changes nothing. - A deleted article now frees its slug. +- The slug backfill writes in batches of 500 rows, so a large database is migrated without one long transaction, and a start that was interrupted resumes where it stopped. ## Published releases diff --git a/features/articles_pagination_api.feature b/features/articles_pagination_api.feature index 004a963..75dab2b 100644 --- a/features/articles_pagination_api.feature +++ b/features/articles_pagination_api.feature @@ -189,3 +189,76 @@ Feature: Paginated article listing Given the repository fails on its next call When a client sends GET /v1/article/ Then the status is 500 and the body is "Failed to retrieve articles" + + Rule: counts.suggestions + + Background: + Given counts holds all, online, draft and suggestions, and the rest of the body shape ({"items","total","page","size","counts"}) is unchanged + And suggestions is the number of visible articles whose seo_status is "suggestions" + And it ignores q, status, site, seo, sort, page and size, like the other counts + And an article whose seo_status is "hidden" or "ready" is not counted + And a suggestion code listed in the article's seo_dismissed is never counted + And it equals the total of GET /v1/article/?seo=suggestions sent with no other filter by the same caller + + Scenario: counts carries the suggestions count + Given 4 online and 2 draft articles of which 3 have suggestions + When a verified account sends GET /v1/article/ + Then counts is {"all": 6, "online": 4, "draft": 2, "suggestions": 3} + + Scenario: It is zero when nothing needs attention + Given 5 articles that all have a clean SEO check + When a verified account sends GET /v1/article/ + Then counts.suggestions is 0 + + Scenario: It equals the total of the suggestions filter + Given 30 articles of which 12 have suggestions + When a verified account sends GET /v1/article/?seo=suggestions + Then total is 12 and counts.suggestions is 12 + + Scenario Outline: It ignores every other query parameter + Given 6 articles of which 3 have suggestions, on the sites "renew" and "journal" + When a verified account sends GET /v1/article/? + Then counts.suggestions is 3 + + Examples: + | params | + | q=tile | + | status=draft | + | site=journal | + | seo=ready | + | seo=hidden | + | sort=title:asc | + | size=5&page=99 | + + Scenario: Hidden articles are not counted + Given an article with suggestions that is noindex + When a verified account sends GET /v1/article/ + Then counts.suggestions is 0 + + Scenario: Dismissed suggestions are not counted + Given an article whose only suggestion is dismissed + When a verified account sends GET /v1/article/ + Then counts.suggestions is 0 + + Scenario: A verified caller counts offline articles too + Given an online and a draft article, both with suggestions + When a verified account sends GET /v1/article/ + Then counts.suggestions is 2 + + Scenario: A public caller counts online articles only + Given an online and an offline article, both with suggestions + When a caller without a verified account sends GET /v1/article/ + Then counts is {"all": 1, "online": 1, "draft": 0, "suggestions": 1} + + Scenario: An empty library + When a verified account sends GET /v1/article/ with no article stored + Then counts is {"all": 0, "online": 0, "draft": 0, "suggestions": 0} + + Scenario: A failure to compute the count fails the request + Given the repository fails while the suggestions are evaluated + When a verified account sends GET /v1/article/ + Then the status is 500 and the body is "Failed to retrieve articles" + + Scenario: Existing fields are unchanged + Then total, page, size, ordering and every item field are as before + And counts.all, counts.online and counts.draft keep their values diff --git a/features/articles_pagination_api.qa.md b/features/articles_pagination_api.qa.md index 7967d68..046a1da 100644 --- a/features/articles_pagination_api.qa.md +++ b/features/articles_pagination_api.qa.md @@ -30,6 +30,21 @@ Helper: decode the body into {items []map, total, page, size int, counts {all, o 18. Use-case tests: the paged list method returns items, total and counts, propagates a repo error (errors.Is on a sentinel), and the unpaged `Filters{}` call used by `purgePlan.go` and `ListMedia.go` still returns every article (existing tests keep passing). 19. Existing tests that decode `GET /v1/article/` as an array (`test/public_article_listing_test.go`, `test/article_test.go`, `test/use_case_baseline_test.go`, features/articles_list.qa.md item 1) are amended to the envelope. List them in the diff. +## counts.suggestions (Go, `test/`, `task test`) + +Build `article.NewHandler(...)` over the real use cases and the extended `mockArticleRepo`, as in the section above. The fixture articles carry bodies that yield suggestions, hidden (noindex) or ready SEO status and a `seo_dismissed` list. Mount on a fresh `chi.NewRouter()` that mirrors `NewArticleRouter` and call `validator.InitValidator()`. + +20. Counts table: 4 online and 2 draft with 3 suggestions gives `counts` exactly `{all:6, online:4, draft:2, suggestions:3}`. Decode into a map and assert the key set is exactly those four. +21. Zero case: 5 clean articles gives `suggestions` 0. Empty library gives all four at 0. +22. Equality: 30 articles, 12 with suggestions; `GET /?seo=suggestions` gives `total` 12 and `counts.suggestions` 12. +23. Independence table: `q=tile`, `status=draft`, `site=journal`, `seo=ready`, `seo=hidden`, `sort=title:asc`, `size=5&page=99` all give 3. +24. Exclusions: a noindex article with suggestions and an article whose only suggestion is dismissed are not counted; one with two suggestions of which one is dismissed is counted once. +25. Visibility: a verified caller counts an online and a draft article (2); an anonymous caller and an unverified caller count the online one only, with `draft` 0. +26. Repository failure while evaluating gives 500 "Failed to retrieve articles" and no partial body. +27. Regression: the existing assertions on `total`, `page`, `size`, `counts.all/online/draft` and items stay green without edits. +28. Docs, read by hand: `docs/api/articles.md` (example JSON gets `"suggestions"`, and the `counts` bullet says global, visibility-scoped, noindex and dismissed excluded, equal to the unfiltered `seo=suggestions` total); `web/src/data/apiCatalogue.ts` list response fields with its spec and `IntegrationView.spec.ts` counts; `docs/release-notes.md` line. +29. Scoped mutation through `task mutate` only: `MUTATE_FILES=ListArticles.go MUTATE_RUN='TestArticle.*Count' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`, plus any other file touched by the change. Re-check LIVED or NOT COVERED suspects without `MUTATE_RUN`. Then `task lint:go` and `task fmt`. + ## Docs (verified by reading the diff) - `docs/api/articles.md`: new "Pagination, search, sort and counts" section (parameters including `sort` values, default and id tie-break, envelope, ordering, errors) and a "Breaking change" note, like the existing format note: GET /v1/article/ no longer returns a bare array; read `items`. diff --git a/features/authors_backend.feature b/features/authors_backend.feature index bfb7c47..ede01a1 100644 --- a/features/authors_backend.feature +++ b/features/authors_backend.feature @@ -865,8 +865,64 @@ Feature: Authors backend, public bylines that articles credit in order When it is read back from the repository Then the authors come back as 7, 5, 6 + Rule: Total in X-Total-Count + + Background: + Given GET /v1/author/ keeps its {"items","total","page","size"} body and also sends total in the X-Total-Count header on every 200 + And the header equals the body total + And X-Total-Count is already in the CORS ExposedHeaders + And a refused request sends no X-Total-Count + + Scenario: The total is also sent as a header + Given 30 authors + When a verified account sends GET /v1/author/?size=5 + Then the body total is 30 and the header X-Total-Count is "30" + + Scenario: The header follows the search + Given 30 authors of which 7 match "mar" + When a verified account sends GET /v1/author/?q=mar + Then X-Total-Count is "7" and the body total is 7 + + Scenario: A page past the end keeps the total in the header + Given 30 authors + When a verified account sends GET /v1/author/?page=3&size=100 + Then items is [], the body total is 30 and X-Total-Count is "30" + + Scenario: No match + When a verified account sends GET /v1/author/?q=nobody + Then items is [], the body total is 0 and X-Total-Count is "0" + + Scenario Outline: Every reader gets the header + When GET /v1/author/ is sent with + Then the status is 200 and X-Total-Count equals the body total + + Examples: + | credentials | + | the owner's jwt cookie | + | a verified admin's jwt cookie | + | a verified account with another role | + | a valid API key | + + Scenario Outline: Refused requests send no header + When GET /v1/author/? is sent with + Then the status is and no X-Total-Count is sent + + Examples: + | params | credentials | status | + | size=30 | a verified account | 400 | + | page=0 | a verified account | 400 | + | page=abc | a verified account | 400 | + | | an unverified account's jwt cookie | 403 | + | | no credentials | 401 | + + Scenario: CORS exposes the header + Given CORS_ALLOWED_ORIGINS includes "https://app.example.com" + When a preflighted GET /v1/author/ comes from that origin + Then Access-Control-Expose-Headers lists X-Total-Count + Rule: Documentation Scenario: The docs describe the new surface - Then docs/api.md documents the /v1/author routes with auth and roles, the error tokens, the author shape, article author_ids and authors, the author field of /v1/auth/me and the authors_updated media field + Then docs/api.md names X-Total-Count on the GET /v1/author/ row + And docs/api.md documents the /v1/author routes with auth and roles, the error tokens, the author shape, article author_ids and authors, the author field of /v1/auth/me and the authors_updated media field And docs/architecture.md mentions the author aggregate, its repository and its use cases diff --git a/features/authors_backend.qa.md b/features/authors_backend.qa.md index d56feb0..80992e4 100644 --- a/features/authors_backend.qa.md +++ b/features/authors_backend.qa.md @@ -102,6 +102,13 @@ Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `te 57. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES= MUTATE_RUN='TestAuthor' MUTATE_WORKERS=2 task mutate -- `; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`. 58. Docs, checked by reading the files: docs/api.md documents the five /v1/author routes (GET, POST, POST /me, PATCH, DELETE) with auth and roles, error tokens, the author and article-author shapes, `author_ids`, the `/auth/me` `author` field and `authors_updated`; docs/architecture.md names the author aggregate, repository and use cases. +## M. X-Total-Count (Go, `test/`, `task test`) + +59. On the same `mockAuthorRepo` and router as item 4, assert `X-Total-Count` equals the decoded body `total` for: size=5 of 30, `q=mar` with 7 matches, `page=3&size=100` (items empty, total 30), `q=nobody` (0), and for owner, admin, "editor" and API key. Assert the header is absent for `size=30`, `page=0`, `page=abc`, unverified (403) and none (401). The body assertions of item 4 stay unchanged. +60. On the SQLite contract harness, the author list total (and so the header) follows `q` and does not depend on `page`/`size`; `task test:mysql` and `task test:postgres` run the same case. +61. CORS preflight from an allowed origin exposes `X-Total-Count`. +62. Scoped `task mutate` on the author list use case and handler files only. `docs/api.md` author row and `web/src/data/apiCatalogue.ts` (`X-Total-Count` response header on `GET /v1/author/`) with its spec, read by hand. No comment added. + ## Manual - MySQL and Postgres: run the contract suite with the opt-in env vars against real servers. diff --git a/features/seo_sites_articles_integration.feature b/features/seo_sites_articles_integration.feature index 2fba696..3539b83 100644 --- a/features/seo_sites_articles_integration.feature +++ b/features/seo_sites_articles_integration.feature @@ -8,8 +8,8 @@ Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration Given the screens are SitesView (/settings/sites), ArticleView (/article, also reached as /articles), IntegrationView (/integration) and the Editor SEO panel (/article/:id/edit), inside DashboardShell, per docs/design/routes.md And the visual spec of record is docs/design/screens/sites.md ("Sites: SEO defaults", 5 states), articles.md ("Articles: search status", 3 states), integration.md ("Search and sharing"), editor.md (SuggestionList), docs/design/components.md (SeoStatusChip, SuggestionList), docs/design/seo-slices.md and docs/design/states.md ("SEO round"). Exact copy is read from those files and is not repeated here unless a scenario needs it And the board frames of docs/design/source/DirectoryFrame.dc.html (Sites form, SuggestionList), AppFrame.dc.html (Articles rows, cards and toolbar) and IntegrationPage.dc.html (group and index) are the layout tiebreaker - And this slice is SPA only and uses the API of features/seo_data_backend.feature and features/seo_output_backend.feature without change. No Go file changes - And the SPA calls, with relative URLs through useApi only: GET /v1/site/, POST /v1/site/ and PATCH /v1/site/{id} (site_name, title_template, default_description), GET /v1/article/ with the optional seo=suggestions parameter, and PATCH /v1/article/{id} with seo_dismissed. The Integration page makes no request + And this slice is SPA only and uses the API of features/seo_data_backend.feature and features/seo_output_backend.feature, plus counts.suggestions added by features/articles_pagination_api.feature. No other Go file changes + And the SPA calls, with relative URLs through useApi only: GET /v1/site/, POST /v1/site/ and PATCH /v1/site/{id} (site_name, title_template, default_description), GET /v1/article/ with the optional seo=suggestions parameter, whose response also carries counts.suggestions, and PATCH /v1/article/{id} with seo_dismissed. The Integration page makes no request And an article list item carries seo_status ("ready", "suggestions" or "hidden") and seo_suggestions (a count) And the SPA never re-implements a suggestion rule or an SEO status. It draws the status and the count the list returned And the API refuses a title_template without the exact, case-sensitive token %title% with 400 invalid_title_template, and refuses site_name above 100, title_template above 200 and default_description above 320 characters @@ -306,23 +306,30 @@ Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration And the switch, the chips and ?seo=suggestions behave exactly as with several sites Scenario Outline: The count on the switch - Given the count request answers + Given the switch is on or off + When the list response Then the switch reads