Files
RenewCMS/features/articles_pagination_api.feature
T

265 lines
13 KiB
Gherkin

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/?<query>
Then the body has page <page>, size <size>, total 60 and <count> 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=<sort>
Then the item ids are <ids>
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=<status>
Then total is <total>, 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/?<query>
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/?<params>
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