265 lines
13 KiB
Gherkin
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
|