391 lines
26 KiB
Gherkin
391 lines
26 KiB
Gherkin
Feature: Editorial article list by story and language
|
||
A signed-in editor lists the articles of the back-office either as one row per story (all languages), or per language, with coverage, a "not in this language" view, an author filter and counts. The grouped response is an explicit opt-in on the same endpoint (group=story). Without it, GET /v1/article/ keeps exactly the flat envelope of features/articles_pagination_api.feature. Only versions the caller can read contribute titles, authors, timestamps, coverage, results and counts.
|
||
|
||
Background:
|
||
Given the storage, translation and lifecycle rules of features/article_localization_storage.feature, features/article_translation_create.feature and features/article_story_lifecycle.feature apply
|
||
And error responses are plain text, and a malformed query answers 400 with apperr.BodyErrorMessage, validated with validate tags through validator.Validate.Struct
|
||
And site "journal" has enabled languages en (default), fr, de, ar and disabled languages es, it
|
||
And the "Journal" dataset holds 8 stories and 17 versions (article id, lang, state, updated_at, byline):
|
||
| story | article ids and versions | byline | updated_at of the latest version |
|
||
| 1 | 101 en online, 102 fr draft, 103 de online, 104 ar draft | Marta Alves | 2026-10-30 10:00 |
|
||
| 2 | 201 en online, 202 fr online, 203 de online | Camille Roche | 2026-10-29 09:00 |
|
||
| 3 | 301 en online, 302 fr online | Luis Ferrer | 2026-10-28 09:00 |
|
||
| 4 | 401 en draft | Marta Alves | 2026-10-27 09:00 |
|
||
| 5 | 501 en online, 502 de draft | Luis Ferrer | 2026-10-14 09:00 |
|
||
| 6 | 601 en online, 602 fr draft | Camille Roche | 2026-10-13 09:00 |
|
||
| 7 | 701 en online, 702 de online | Marta Alves | 2026-10-12 09:00 |
|
||
| 8 | 801 es online | Luis Ferrer | 2026-10-10 09:00 |
|
||
And an "owner" holds every content permission at scope all
|
||
And a "reader" holds content.read at scope all only: drafts of other users are hidden and no create_translation capability is held
|
||
And a "restricted writer" holds content.create and content.read at scope own
|
||
And a caller is "editorial" when the jwt cookie carries a user_id claim of a verified user whose role is resolved; every other caller is "public"
|
||
And the new query parameters are: group (only "story"), lang (a supported language tag, normalized as in features/article_localization_storage.feature), tab (all, missing; review is reserved for features/ of release 2 and answers 400 until then), author (a byline id, integer >= 1)
|
||
And the existing parameters page, size, q, status, site, seo and sort keep their meaning, defaults and validation; filter changes are the client's job to page-reset
|
||
And a visible version is one the caller may read under the scopes of features/roles_permissions_content_scope.feature; a hidden version is never counted, titled, named in coverage, in missing_langs or in an error
|
||
And the repository query is portable across SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL: no dialect-only construct, request text never reaches the SQL, and the access filter is applied inside the query, not after paging
|
||
|
||
Rule: Public lists are unchanged
|
||
|
||
Scenario: A bare request is still the flat envelope
|
||
Given the "Journal" dataset and an owner session
|
||
When the client sends GET /v1/article/
|
||
Then the body keys are exactly items, total, page, size and counts, and each item is the article object of the version, as before this slice
|
||
And total is 17 versions' worth of visible articles, not 8
|
||
|
||
Scenario: lang, tab and group are not read unless asked
|
||
Given the "Journal" dataset
|
||
When a public client sends GET /v1/article/?size=5
|
||
Then the body is identical to what it was before this slice, with lang and story_id on each item as defined in features/article_localization_storage.feature
|
||
|
||
Scenario Outline: group=story needs an editorial caller
|
||
Given the "Journal" dataset
|
||
When <caller> sends GET /v1/article/?group=story
|
||
Then the response 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:
|
||
| caller |
|
||
| a client without a jwt cookie |
|
||
| a client with a malformed or expired cookie |
|
||
| a client whose user is not verified |
|
||
|
||
Scenario Outline: Invalid new parameters answer 400
|
||
Given an owner session
|
||
When the client sends GET /v1/article/?<query>
|
||
Then the response 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 |
|
||
| group=stories |
|
||
| group= |
|
||
| group=story&tab=review |
|
||
| group=story&tab=published |
|
||
| group=story&tab= |
|
||
| tab=missing |
|
||
| group=story&lang=english |
|
||
| group=story&lang=xx |
|
||
| group=story&lang= |
|
||
| group=story&author=0 |
|
||
| group=story&author=abc |
|
||
| group=story&author= |
|
||
| author=-1 |
|
||
|
||
Rule: Grouped rows, one per story
|
||
|
||
Scenario: All languages, page 1
|
||
Given the "Journal" dataset and an owner session
|
||
When the client sends GET /v1/article/?group=story&size=5
|
||
Then the response status is 200 and total is 8, page 1, size 5
|
||
And the story_id of the items are 1, 2, 3, 4, 5 in that order
|
||
And counts.all is 8
|
||
|
||
Scenario: All languages, page 2 is the last
|
||
When the owner sends GET /v1/article/?group=story&size=5&page=2
|
||
Then the story_id of the items are 6, 7, 8 and total is 8
|
||
|
||
Scenario: A page beyond the last is empty with the real total
|
||
When the owner sends GET /v1/article/?group=story&size=5&page=99
|
||
Then items is [] and total is 8 and counts.all is 8
|
||
|
||
Scenario: A grouped row describes the representative version and the story
|
||
When the owner sends GET /v1/article/?group=story
|
||
Then the item of story 1 carries the fields of article 101 (id 101, lang "en", title, slug, is_online, site_id, story_id 1 and the other list fields of the flat item)
|
||
And it adds story_updated_at "2026-10-30T10:00:00Z", story_authors (the union of the bylines of the readable versions, without duplicates) and capabilities of article 101
|
||
And it adds coverage and missing_langs
|
||
|
||
Scenario Outline: The representative is the default-language version, else the first readable by language code
|
||
Given the "Journal" dataset and an owner session
|
||
When the client sends GET /v1/article/?group=story
|
||
Then the representative id of story <story> is <id> and its lang is "<lang>"
|
||
|
||
Examples:
|
||
| story | id | lang |
|
||
| 1 | 101 | en |
|
||
| 4 | 401 | en |
|
||
| 8 | 801 | es |
|
||
|
||
Scenario: The representative falls back when the default version is unreadable
|
||
Given story 5 holds a draft en article created by another user
|
||
When a restricted writer sends GET /v1/article/?group=story
|
||
Then the representative of story 5 is the first readable version by language code, never the hidden one
|
||
And no field of the row names the hidden one
|
||
|
||
Scenario: story_updated_at is the latest readable version update
|
||
Given article 102 (fr draft of story 1, creator another user) was updated at 2026-11-01 08:00 and is hidden from the caller
|
||
When a restricted writer or a reader sends GET /v1/article/?group=story
|
||
Then story 1's story_updated_at ignores article 102
|
||
When the owner sends the same request
|
||
Then story 1's story_updated_at is "2026-11-01T08:00:00Z"
|
||
|
||
Scenario Outline: Coverage lists readable versions in the chip order, then the languages that can be created
|
||
Given the "Journal" dataset and an owner session
|
||
When the client sends GET /v1/article/?group=story
|
||
Then the coverage of story <story> is <coverage>
|
||
And each entry has id, lang, title, is_online, language_enabled and capabilities, like an entry of "translations" in features/article_story_lifecycle.feature
|
||
And missing_langs of story <story> is <missing>
|
||
|
||
Examples:
|
||
| story | coverage | missing |
|
||
| 1 | en, ar, de, fr | [] |
|
||
| 3 | en, fr | ["ar","de"] |
|
||
| 4 | en | ["ar","de","fr"] |
|
||
| 8 | es (language_enabled false) | ["en","ar","de","fr"] |
|
||
|
||
Scenario: Coverage order is default, other enabled by tag, disabled by tag; missing is listed by the same rule
|
||
Given story 8 gains a readable it version, and story 8's default en version does not exist
|
||
Then the coverage of story 8 is es then it, both with language_enabled false
|
||
And missing_langs starts with the default language en
|
||
|
||
Scenario: A caller who cannot create gets no missing_langs
|
||
When a reader sends GET /v1/article/?group=story
|
||
Then missing_langs is [] on every row
|
||
And the coverage lists only the readable versions of each story
|
||
|
||
Scenario: The row of a story whose versions are all hidden does not exist
|
||
Given story 4's only version is a draft of another user
|
||
When a restricted writer sends GET /v1/article/?group=story
|
||
Then story 4 is in no item, in total or in counts
|
||
And total is the same as if article 401 did not exist
|
||
|
||
Scenario: Hidden versions leave no trace
|
||
Given the "Journal" dataset and a restricted writer who cannot read the drafts 102, 104, 401, 502 and 602
|
||
When the restricted writer sends GET /v1/article/?group=story&size=100
|
||
Then the items, total, counts and every coverage and missing_langs equal those of a database in which those articles do not exist
|
||
And no response field or header names them
|
||
|
||
Scenario: Unassigned articles are their own story with an empty language
|
||
Given article 30 has lang "" and its own story
|
||
When the owner sends GET /v1/article/?group=story
|
||
Then its row has lang "", coverage with its single version, and missing_langs []
|
||
And it never appears in the "not in" view of any language
|
||
|
||
Rule: Language selected means one row per version of that language
|
||
|
||
Scenario: French selected
|
||
When the owner sends GET /v1/article/?group=story&lang=fr&size=5
|
||
Then total is 4 and the story_id of the items are 1, 2, 3, 6
|
||
And each representative is the fr version: ids 102, 202, 302, 602
|
||
And each row still carries the whole story's coverage and story_authors
|
||
|
||
Scenario: The row opens the selected version and sorts by its update
|
||
Given the fr versions have the updated_at values 2026-10-30, 2026-10-29, 2026-10-28 and 2026-10-13
|
||
When the owner sends GET /v1/article/?group=story&lang=fr
|
||
Then the order is stories 1, 2, 3, 6
|
||
|
||
Scenario: Disabled languages can be selected
|
||
When the owner sends GET /v1/article/?group=story&lang=es
|
||
Then total is 1 and the item is story 8, representative 801 and its coverage entry has language_enabled false
|
||
|
||
Scenario: A language nobody has gives an empty list
|
||
When the owner sends GET /v1/article/?group=story&lang=it
|
||
Then items is [] and total is 0
|
||
|
||
Scenario: lang also narrows the flat list
|
||
When the owner sends GET /v1/article/?lang=fr
|
||
Then the flat envelope holds the 4 fr articles, total is 4 and no item has another lang
|
||
And counts.all, counts.online and counts.draft count the fr versions only and still ignore q and status
|
||
|
||
Rule: The not-in view lists stories without a readable version
|
||
|
||
Scenario: Not in French
|
||
When the owner sends GET /v1/article/?group=story&lang=fr&tab=missing&size=5
|
||
Then total is 4 and the story_id of the items are 4, 5, 7, 8
|
||
And each representative is as in the all-languages view (401, 501, 701, 801)
|
||
And missing_langs of each row contains "fr"
|
||
|
||
Scenario: Needs translation, no language selected
|
||
When the owner sends GET /v1/article/?group=story&tab=missing&size=5
|
||
Then total is 7 and the stories are 2, 3, 4, 5, 6, 7, 8 (story 1 has every enabled language)
|
||
|
||
Scenario: Missing means no readable version, not no stored version
|
||
Given a hidden fr draft exists on story 4
|
||
When a restricted writer sends GET /v1/article/?group=story&lang=fr&tab=missing
|
||
Then story 4 is listed as not in French
|
||
And nothing in the response tells whether a fr version exists
|
||
|
||
Scenario: Sites that do not enable the language are excluded
|
||
Given site "atelier" enables en, en-GB and fr only, with one story that has en and fr versions only
|
||
When the owner sends GET /v1/article/?group=story&lang=de&tab=missing
|
||
Then no atelier story is listed
|
||
When the owner sends GET /v1/article/?group=story&lang=es&tab=missing
|
||
Then no story is listed, because es is disabled on journal
|
||
|
||
Scenario: Needs translation counts only languages the caller can create
|
||
When a reader sends GET /v1/article/?group=story&tab=missing
|
||
Then total is 0 and items is []
|
||
And a reader's lang=fr tab=missing still lists the stories without a readable fr version
|
||
|
||
Scenario: Nothing missing
|
||
Given site "press" with 3 stories, each with en and fr versions, and enabled languages en, fr
|
||
When the owner sends GET /v1/article/?group=story&tab=missing&site=press
|
||
Then items is [], total is 0 and counts.missing is 0
|
||
|
||
Rule: Filters apply to one readable version together
|
||
|
||
Scenario: Status matches a readable version of the story
|
||
When the owner sends GET /v1/article/?group=story&status=draft
|
||
Then the stories are 1, 4, 5, 6 and total is 4
|
||
|
||
Scenario: Combined filters need a single readable version to satisfy them together
|
||
Given story 1 holds article 101 (en, online, byline Marta Alves) and article 102 (fr, draft, byline Camille Roche)
|
||
When the owner sends GET /v1/article/?group=story&status=draft&author=<id of Marta Alves>
|
||
Then story 1 is not listed, because no single version is both a draft and by Marta Alves
|
||
When the owner sends GET /v1/article/?group=story&status=online&author=<id of Marta Alves>
|
||
Then story 1 is listed
|
||
When the owner sends GET /v1/article/?group=story&status=draft&author=<id of Camille Roche>
|
||
Then story 1 is listed
|
||
|
||
Scenario: story_authors is the union even when the filter matched one version
|
||
Given the same story 1
|
||
When the owner sends GET /v1/article/?group=story&author=<id of Camille Roche>
|
||
Then story 1 is listed and its story_authors holds Marta Alves and Camille Roche
|
||
|
||
Scenario: Search matches any readable title of the story
|
||
Given article 102 is titled "Comment nous chiffrons une rénovation" and article 101 "How we price a bathroom renovation"
|
||
When the owner sends GET /v1/article/?group=story&q=RENOVATION
|
||
Then story 1 is listed with the title of its representative (101)
|
||
And the search ignores case and accents like features/articles_pagination_api.feature
|
||
When the owner sends GET /v1/article/?group=story&lang=fr&q=chiffrons
|
||
Then story 1 is listed with article 102 as representative
|
||
When the owner sends GET /v1/article/?group=story&lang=en&q=chiffrons
|
||
Then items is []
|
||
|
||
Scenario: Search in the not-in view matches readable source titles
|
||
When the owner sends GET /v1/article/?group=story&lang=fr&tab=missing&q=Spring
|
||
Then story 4 is listed
|
||
|
||
Scenario: Author in language mode
|
||
When the owner sends GET /v1/article/?group=story&lang=fr&author=<id of Camille Roche>
|
||
Then the stories are 2 and 6 (the fr version's own byline decides)
|
||
|
||
Scenario: Site filter
|
||
When the owner sends GET /v1/article/?group=story&site=press
|
||
Then only press stories are listed, and an unknown site key gives items [] and total 0 as for the flat list
|
||
|
||
Scenario: SEO filter on one version
|
||
Given article 502 has suggestions and article 501 has none
|
||
When the owner sends GET /v1/article/?group=story&seo=suggestions
|
||
Then story 5 is listed once, with its own representative 501
|
||
|
||
Scenario: All filters combine with paging and stay stable
|
||
Given 12 stories match
|
||
When the owner requests pages 1, 2 and 3 of ?group=story&size=5 with the same filters
|
||
Then the pages hold 5, 5 and 2 stories with no repeat, and total is 12 on each
|
||
|
||
Rule: Sort applies to the whole list at story level
|
||
|
||
Scenario Outline: Sort orders stories, not the page
|
||
Given the "Journal" dataset and an owner session
|
||
When the client sends GET /v1/article/?group=story&sort=<sort>&size=100
|
||
Then the story order is <order>
|
||
|
||
Examples:
|
||
| sort | order |
|
||
| updated_at:desc | 1,2,3,4,5,6,7,8 |
|
||
| updated_at:asc | 8,7,6,5,4,3,2,1 |
|
||
| title:asc | by representative title, then story_id descending |
|
||
| title:desc | by representative title descending, then story_id descending |
|
||
| status:asc | drafts first by representative is_online, then story_id descending |
|
||
| created_at:asc | by the earliest created_at among readable versions |
|
||
|
||
Scenario: Ties end with story_id descending
|
||
Given two stories with the same latest update
|
||
Then the one with the higher story_id comes first on every page
|
||
|
||
Rule: Counts
|
||
|
||
Background:
|
||
Given the grouped body is {"items","total","page","size","counts"}
|
||
And counts holds all, online, draft, suggestions and missing, in snake_case
|
||
And in grouped mode the counts respect lang, q, author, seo and site but never status or tab, so the tab counts stay comparable; they count the unit of the mode (stories, or versions of the selected language)
|
||
And online and draft count units with a readable online or draft version (the selected version in language mode)
|
||
And missing counts the units the "not in" view would list under the same lang, q, author, seo and site
|
||
And suggestions keeps its meaning (units with a readable version whose seo_status is suggestions, ignoring every other parameter)
|
||
And the flat list counts are unchanged
|
||
|
||
Scenario: Counts of the Journal dataset, all languages
|
||
When the owner sends GET /v1/article/?group=story
|
||
Then counts is {"all": 8, "online": 7, "draft": 4, "suggestions": 0, "missing": 7}
|
||
|
||
Scenario: Counts of the Journal dataset, French
|
||
When the owner sends GET /v1/article/?group=story&lang=fr
|
||
Then counts.all is 4, counts.online is 2, counts.draft is 2 and counts.missing is 4
|
||
|
||
Scenario: Counts follow the search
|
||
When the owner sends GET /v1/article/?group=story&q=zinc
|
||
Then items is [], total is 0 and counts is {"all": 0, "online": 0, "draft": 0, "suggestions": 0, "missing": 0}
|
||
And suggestions is the only count that ignores q
|
||
|
||
Scenario: Counts follow the author filter
|
||
When the owner sends GET /v1/article/?group=story&author=<id of Marta Alves>
|
||
Then total is 3, the stories are 1, 4, 7 and counts.all is 3
|
||
|
||
Scenario: Counts do not move with status or tab
|
||
When the owner sends GET /v1/article/?group=story&lang=fr&status=draft&tab=missing
|
||
Then counts.all is 4, counts.online is 2 and counts.draft is 2, the same as without status and tab
|
||
|
||
Scenario: A reader's counts cover readable versions only
|
||
When a reader sends GET /v1/article/?group=story
|
||
Then counts.draft is 0 and counts.all equals the number of stories with an online version
|
||
|
||
Scenario: Total versus counts
|
||
When the owner sends GET /v1/article/?group=story&tab=missing
|
||
Then total is counts.missing and total is 7
|
||
|
||
Rule: Failure
|
||
|
||
Scenario: Repository failure answers 500
|
||
Given the repository fails on its next call
|
||
When the owner sends GET /v1/article/?group=story
|
||
Then the status is 500 and the body is "Failed to retrieve articles"
|
||
|
||
Scenario: A failure while counting fails the request
|
||
Given the repository fails while the counts are evaluated
|
||
Then the status is 500 and the body is "Failed to retrieve articles"
|
||
|
||
Rule: Persistence portability
|
||
|
||
Scenario: The grouped, access-filtered query passes the same cases on every database
|
||
Given the "Journal" dataset, a hidden-version dataset and the cases above (all languages, lang, tab=missing, status+author together, q with accents, sort whitelist, paging, counts)
|
||
Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL with identical ids, totals and counts
|
||
And the result does not depend on the database collation for the id sets
|
||
And no raw SQL uses a dialect-only construct
|
||
|
||
Rule: The SPA, editorial list screen
|
||
|
||
Background:
|
||
Given /article keeps its route and URL conventions of features/articles_pagination_screen.feature, and the screen follows docs/design/screens/localization-list.md group L7
|
||
And ArticlesPage always sends group=story; the URL carries site, lang, tab (omitted when all), q, status, author, seo, page and size, defaults omitted, and any filter change drops page
|
||
And an invalid value in the URL falls back to its default and is replaced in the URL
|
||
And the unit label (UnitLabel, aria-live polite) states the unit: "8 articles", "4 French versions", "4 articles not in French", "7 articles need translation"
|
||
And the tab "Needs review" is not rendered (it ships with release 2); loc.l7.review is not part of this slice
|
||
And the tabs: with no language "All articles" and "Needs translation"; with a language "All", "Published" (status=online), "Drafts" (status=draft) and "Not in {Language}", each with its count from counts
|
||
And the row menu on every width lists Open {language}, Open another language, Add translation, Publish or Unpublish {language}, Copy API URL, Delete {language} version, each shown when its capability or condition holds (docs/design/localization-tables.md "Row actions"), and every row has the menu button
|
||
And coverage chips follow docs/design/localization-tables.md "Coverage chips for many languages": 5 shown at 1440 and 4 at 1024 and 390, then "+N" named "N more languages: ..."; dashed chips only for missing_langs and buttons on desktop only
|
||
And below md the filters open a sheet (Site, Language, Status, Author, SEO, Clear all, "Show N results") and the tabs scroll with an edge fade and "Scroll tabs left/right" buttons that keep Not in {Language} reachable
|
||
And copy comes from the design; no revision number, id or HTTP status name is shown
|
||
|
||
Scenario Outline: The design states of group L7 are rendered from the matching response
|
||
Given the Journal dataset (and Press, Atelier or the limited reader where the state names it) mocked on GET /v1/article/ as above
|
||
When the user opens <route>
|
||
Then the screen shows state <state> as drawn: <visible>
|
||
|
||
Examples:
|
||
| state | route | visible |
|
||
| loc.l7.all | /article | 5 of 8 rows, unit "8 articles", tabs "All articles" "Needs translation", pager page 1 of 2 |
|
||
| loc.l7.page2 | /article?page=2 | 3 of 8 rows, pager page 2 of 2 |
|
||
| loc.l7.fr | /article?lang=fr | unit "4 French versions", tabs All, Published, Drafts, Not in French |
|
||
| loc.l7.frMissing | /article?lang=fr&tab=missing | unit "4 articles not in French" |
|
||
| loc.l7.emptyMissing | /article?tab=missing&site=press | no summary, no pager, the nothing-missing empty state |
|
||
| loc.l7.noResults | /article?q=zinc | no-results empty state, every count 0 |
|
||
| loc.l7.frLoading | /article?lang=fr (request pending) | counts shown as "–", skeleton rows, aria-busy |
|
||
| loc.l7.listFail | /article?lang=fr (request fails) | failure state with the filters kept and a retry |
|
||
| loc.l7.limited | /article as a reader without create | no dashed chips, no Add translation item |
|
||
| loc.l7.author | /article?author=<Marta Alves id> | 3 articles, filter chip and "Clear all" |
|
||
| loc.l7.langFilter | language filter opened | Enabled and Disabled groups |
|
||
| loc.l7.rowMenu | a row menu opened | the same actions on desktop and mobile |
|
||
| loc.l7.filters | /article?lang=fr&status=draft at 390 | filter sheet with Journal, French, Draft: "Show 2 results" |
|
||
| loc.l7.tabsScroll | tabs scrolled (1440, 390) | Not in French reachable; fade and chevrons |
|
||
| loc.l7.long | /article?site=atelier (8 languages) | chips collapse to +3, +4, +4; Draft and SEO filters |
|