Feature: Paginated article listing As an API consumer or the RenewCMS back-office I want GET /v1/article/ to always answer one page of articles with the total and the tab counts So that large sites list articles quickly and the SPA can search, filter and page on the server Background: Given GET /v1/article/ is mounted under jwtauth.Verifier only (features/public_article_listing.feature) And a caller is "verified" when the jwt cookie carries a user_id claim whose user has is_verified true, and "public" otherwise (missing, malformed, expired or unverified) And a public caller only ever sees articles with is_online true; a verified caller sees every article And BREAKING CHANGE: the body is no longer a bare JSON array; every successful request, with or without query parameters, answers {"items": [...], "total": N, "page": P, "size": S, "counts": {"all": A, "online": O, "draft": D}} in snake_case And each item is the existing article object (id, title, body, body_format, is_online, created_at, updated_at) and items is [] rather than null And paging follows the Activity convention: page is an integer >= 1 (default 1), size is one of 5, 25, 50, 100 (default 25) And q (optional, max 200 characters; case-insensitive, accent-insensitive, matches anywhere in the title) and status (optional, online or draft) filter the items And total is the number of visible articles matching q and status, before paging; page and size echo the values applied And counts ignores q and status, so the tab counts do not move while the user searches; it respects visibility: for a public caller all equals online and draft is 0 And status=draft requested by a public caller matches nothing: items [], total 0 And sort (optional) is one of title:asc, title:desc, status:asc, status:desc, created_at:asc, created_at:desc, updated_at:asc, updated_at:desc; the default is updated_at:desc; status orders by is_online (asc puts drafts first); every ordering ends with id descending as the tie-break, so pages are stable and never overlap And sort is mapped to a column and direction through a fixed whitelist in the repository, so request text never reaches the SQL; the same ordering applies on the search path (q) before slicing And a page beyond the last answers 200 with items [] and the real total And an invalid page, size, status, sort or an over-long q answers 400 with the plain-text body "The request cannot be processed due to a mismatch in the format of the body." (apperr.BodyErrorMessage), like GET /v1/activity, validated with validate tags through validator.Validate.Struct And format is validated first: an invalid format still answers 400 "The format must be one of: markdown, html, json." And ListArticlesUseCase reaches the data only through article.Repository; the handler never touches a repository; the repository query uses portable SQL (COUNT, LIMIT/OFFSET, ORDER BY, LIKE) And article.Filters keeps Page 0 and Size 0 meaning "unpaged, every article" for internal callers (purge plan, media listing), which are unchanged Scenario: A bare request answers the envelope with the defaults Given the repository holds 30 online articles When a public client sends GET /v1/article/ Then the status is 200 And the body is an object with page 1, size 25, total 30, 25 items and counts {"all": 30, "online": 30, "draft": 0} Scenario Outline: Defaults when only one parameter is given Given the repository holds 60 online articles When a public client sends GET /v1/article/? Then the body has page , size , total 60 and items Examples: | query | page | size | count | | page=2 | 2 | 25 | 25 | | page=3 | 3 | 25 | 10 | | size=5 | 1 | 5 | 5 | | size=50 | 1 | 50 | 50 | Scenario: Items are ordered by last update, newest first, then id descending Given online articles id 1 updated 2026-09-01, id 2 updated 2026-09-03, id 3 updated 2026-09-03 When a public client sends GET /v1/article/?size=5 Then the item ids are 3, 2, 1 Scenario Outline: Sort orders the whole list, not just the page Given a verified session and articles | id | title | created_at | updated_at | is_online | | 1 | Banana | 2026-01-01 | 2026-09-03 | true | | 2 | apple | 2026-01-03 | 2026-09-01 | false | | 3 | Cherry | 2026-01-02 | 2026-09-02 | true | When the client sends GET /v1/article/?sort= Then the item ids are Examples: | sort | ids | | updated_at:desc | 1,3,2 | | updated_at:asc | 2,3,1 | | created_at:desc | 2,3,1 | | created_at:asc | 1,3,2 | | title:asc | 2,1,3 | | title:desc | 3,1,2 | | status:asc | 2,3,1 | | status:desc | 3,1,2 | Scenario: No sort parameter keeps the default order Given the same articles When the client sends GET /v1/article/ Then the item ids are 1,3,2 Scenario: Equal sort keys fall back to id descending Given three articles with the same title "Same" and ids 1, 2, 3 When the client sends GET /v1/article/?sort=title:asc Then the item ids are 3,2,1 Scenario: Sort is applied before paging Given 12 online articles whose titles sort alphabetically as ids 1..12 When a client requests ?sort=title:desc&size=5 for pages 1, 2 and 3 Then the pages hold ids 12..8, 7..3 and 2..1 with no repeat Scenario: Sort combines with search and status Given a verified session and draft articles "Tile A", "Tile C", "Tile B" and an online one "Tile D" When the client sends GET /v1/article/?q=tile&status=draft&sort=title:asc Then the item titles are "Tile A", "Tile B", "Tile C", total is 3 and counts is unchanged by q, status and sort Scenario: Pages partition the list with no overlap Given 12 online articles When a client requests ?page=1&size=5, ?page=2&size=5 and ?page=3&size=5 Then the three pages hold 5, 5 and 2 items, total is 12 on each, and no id repeats Scenario: A public caller's total and counts cover online articles only Given 5 online and 3 offline articles When a public client sends GET /v1/article/?size=5 Then total is 5, counts is {"all": 5, "online": 5, "draft": 0} and no item has is_online false Scenario: A verified caller sees offline articles in the page, the total and the counts Given 5 online and 3 offline articles and a valid jwt cookie of a verified user When the client sends GET /v1/article/?size=5 Then total is 8, counts is {"all": 8, "online": 5, "draft": 3} and 3 items have is_online false Scenario: An unverified or invalid session is treated as public Given 5 online and 3 offline articles And the jwt cookie is malformed, or belongs to an unverified user When the client sends GET /v1/article/?size=5 Then total is 5 and no offline article is returned Scenario: A page beyond the last is empty with the real total Given 12 online articles When a client sends GET /v1/article/?page=99&size=5 Then the status is 200, items is [], total is 12, page is 99, size is 5 and counts.all is 12 Scenario: No articles at all Given the repository is empty When a client sends GET /v1/article/ Then the status is 200, items is [], total is 0 and counts is {"all": 0, "online": 0, "draft": 0} Scenario Outline: Status filter Given 4 online and 2 offline articles and a valid jwt cookie of a verified user When the client sends GET /v1/article/?status= Then total is , counts is {"all": 6, "online": 4, "draft": 2} and every item matches the status Examples: | status | total | | online | 4 | | draft | 2 | Scenario: A public caller asking for drafts gets nothing Given 4 online and 2 offline articles When a public client sends GET /v1/article/?status=draft Then items is [], total is 0 and counts is {"all": 4, "online": 4, "draft": 0} Scenario: Search narrows the page and the total but not the counts Given online articles "Choosing lime plaster for old stone walls" and "How we price a bathroom renovation" When a client sends GET /v1/article/?q=PLÂSTER Then total is 1, the only item is the lime plaster article and counts.all is 2 Scenario: Search and status combine with paging Given a verified session and 7 draft articles whose title contains "tile" and 3 online ones When the client sends GET /v1/article/?q=tile&status=draft&size=5&page=2 Then total is 7, the page holds 2 items and counts.draft is 7 Scenario: Search that matches nothing When a client sends GET /v1/article/?q=zzzz Then items is [], total is 0 and counts is unchanged Scenario Outline: Invalid parameters answer 400 like Activity When a client sends GET /v1/article/? Then the status is 400 And the body is "The request cannot be processed due to a mismatch in the format of the body." And the repository was not called Examples: | query | | page=0 | | page=-1 | | page=abc | | page= | | page=1.5 | | size=0 | | size=10 | | size=200 | | size=abc | | status=archived | | status= | | q=<201 characters>| | page=0&size=5 | | page=1&size=1000 | | sort=title | | sort=title:up | | sort=body:asc | | sort= | | sort=updated_at | | sort=title:asc;DROP TABLE | | sort=TITLE:ASC | Scenario: Invalid format is reported first When a client sends GET /v1/article/?page=0&format=pdf Then the status is 400 and the body is "The format must be one of: markdown, html, json." Scenario: format and paging combine Given 3 online articles When a client sends GET /v1/article/?size=5&format=html Then every item carries body_format "html" and total is 3 Scenario: Repository failure answers 500 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