Files
RenewCMS/features/articles_list.qa.md
T

13 KiB

QA: Articles list screen

Implementation note for coder (informational, not a QA assertion)

internal/infrastructure/persistence/repositories/articleRepository.go's FindByFilters gains .Preload("Images"), mirroring Get, so GET /v1/article/ carries real Images arrays for the delete dialog's count. This is a persistence-layer change asserted by the existing var _ domain.Repository = &ArticleRepository{}; it needs no domain or use-case change, since article.Article.Images already exists.

Toast ids follow components.md's action-plus-id convention: "article-published-{id}", "article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}", "article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}", "article-link-copy-failed-{id}".

HTTP-level (Go, test/, task test)

Build article.NewHandler(...) over the real use cases wired to a fake article.Repository (in the style of test/article_test.go's mockArticleRepo), mounted on a fresh chi.NewRouter() mirroring NewArticleRouter in api/router.go (the outer group, no auth, for GET /). Call validator.InitValidator() first.

  1. GET /v1/article/ returns each article's images array populated — seed the fake repository with an article whose Images slice holds two image.Image entries (ids 10 and 11), send GET /v1/article/ with httptest, decode the JSON array, and assert the matching entry's images field is a 2-element array containing those ids (not null and not empty), proving the handler's response reflects FindByFilters's Images data. Because the fake repository's FindByFilters is written by this test rather than GORM, it can't exercise the real .Preload("Images") SQL by itself — this test only proves the handler passes the repository's Images data through to JSON unchanged. The .Preload call itself against a real SQLite file is covered under Manual verification below.

Automated (Vitest + Vue Test Utils, task test:web)

Mock useApi's calls for GET /v1/article/, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}, and the clipboard write (e.g. navigator.clipboard.writeText), per case; no real backend, no httptest server. Mount ArticlesPage inside DashboardShell with a resolved verified session and a test router at /article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates as listed), unless a scenario's Examples row substitutes a smaller fixture.

  1. Default — mock GET /v1/article/ resolving 200 with the 6-article fixture; assert breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually hidden caption reads "Articles, sorted by last updated, newest first".
  2. Loading — mock GET /v1/article/ with an unresolved promise; assert 5 skeleton rows and aria-busy="true" on the list container; resolve 200 with the fixture; assert the Default state's rows replace the skeletons.
  3. Row menu open — from Default, click id 4's ellipsis button; assert the menu opens named "Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a separator then "Delete…"; assert no other row is affected.
  4. Copy API URL succeeds — from Default, mock the clipboard write to resolve, choose "Copy API URL" from id 4's menu; assert the clipboard write was called with "{origin}/v1/article/4" (using the test's window.location.origin); assert a toast was raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API URL for "Winter checklist for timber windows" is on your clipboard."
  5. Copy API URL fails — from Default, mock the clipboard write to reject, choose "Copy API URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon circle-x, title "Couldn't copy the link", and no description text.
  6. Empty — mock GET /v1/article/ resolving 200 with []; assert tabs "All 0" "Online 0" "Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text, action "New article"); click it; assert NewArticleModal opens.
  7. No results — from Default, type "tiles" into the search field; advance past the 120 ms debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match "tiles"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2"; click "Clear search"; assert the Default rows reappear and the search field has focus.
  8. Empty tab, Examples-driven — (a) mock GET /v1/article/ with 4 online-only articles, select the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon pencil-line, title "No drafts", text "Everything you've written is online.", action "New article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0" "Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual check below).
  9. Action in progress — from Default, choose "Publish" from id 4's menu; assert PATCH /v1/article/4/publish is called; assert id 4's row is disabled with busy label "Publishing…" and the other 5 rows stay enabled.
  10. Published — continuing from 9, resolve the PATCH 200 with is_online: true, updated "Just now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast was raised with id "article-published-4", icon circle-check, title "Article published", text ""Winter checklist for timber windows" is now online."
  11. Action failed, publish, Examples-driven — for each of a 500 response and a network rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly; assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row is never touched before the (failed) response, so there is nothing to revert; assert a toast with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is called again.
  12. Action failed, unpublish, Examples-driven — for each of a 500 response and a network rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy); assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is called again.
  13. Row vanished, draft row, Examples-driven — for each of (publish 404), (delete-confirm 404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id "article-not-found-2", icon info, title "That article no longer exists", text "It was deleted somewhere else, so we removed it from the list."
  14. Row vanished, online row — choose "Unpublish" from id 3's menu, resolve the mocked PATCH 404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a toast with id "article-not-found-3", icon info, title "That article no longer exists", text "It was deleted somewhere else, so we removed it from the list."
  15. Delete confirm — from Default (with id 4 carrying 3 images in the mocked GET /v1/article/ response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens with title "Delete this article?", text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete article".
  16. Delete in progress — from the open dialog, click "Delete article"; assert DELETE /v1/article/4 is called; assert both buttons are disabled, the confirm button reads "Deleting…", aria-busy is set, and Esc / backdrop click do not close it.
  17. Delete failed — continuing from 16, resolve the DELETE 500; assert the dialog stays open with the inline alert "Couldn't delete the article, so it's still there. Try again in a moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the dialog.
  18. Deleted — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article deleted", text ""Winter checklist for timber windows" is gone."

Visual (manual verification — desktop and mobile, light and dark)

Reference: docs/design/screens/articles.md and docs/design/source/RenewCMS Screens Back-office.dc.html, group "Articles". Run task visual:check -- articles for each state below and compare against the board frame; add each state's mock setup to web/visual/states.ts first, reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative dates ("2 hours ago", "5 days ago"), so fix now to the value the board's own script assumes for those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026" and the absolute dates shown, e.g. "18 Aug 2026", per the board's base fixture in the source file).

  • Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated columns (confirm against the board frame directly whether a Created column is also shown — components.md's prose and the rendered board table disagree on this).
  • Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's dropdown placement and the brick-red Delete… treatment.
  • Loading: 5 skeleton rows shaped like the real layout.
  • Empty: EmptyState icon file-text, title, text, single "New article" action.
  • No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
  • No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online yet" state's icon (confirm it directly against the board frame, since it isn't given in the prose extraction read for this slice).
  • Action in progress: id 4's row at 50% opacity with a spinner in the status cell and "aria-busy".
  • Published / Action failed toasts: colors, icons and the "Retry" action match the board.
  • Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the app against this spec rather than a board frame.
  • Row vanished: the info toast and the row's removal match the board.
  • Delete confirm / in progress / failed: the dialog's three states match the board exactly, including the inline alert's placement in the failed state.
  • Deleted: toast copy matches, and focus visibly lands on the next row.
  • Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→ move and activate); each row's title link is reachable and Enter opens the editor; each row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus, starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands on the next row (or the page heading if none).
  • Motion: skeleton pulse and modal open/close follow the standard timings and stop under reduced motion.

Manual verification

  • Real GET/PATCH/DELETE round trips against a running server — Vitest only mocks useApi; confirm the real 200/404/500 responses, and the real updated_at-based ordering, against task dev with a live SQLite file, since these automated tests never exercise the actual Go handlers or use cases.
  • Real .Preload("Images") behavior against a live SQLite file — confirm GET /v1/article/ returns accurate, non-empty images arrays for articles with real uploaded images, and [] for articles with none, against a running server, since the HTTP-level Go test above only proves the handler forwards whatever the (fake) repository returns, not that the real GORM query populates it correctly.
  • Real image-count accuracy in the delete dialog — confirm the delete dialog's count matches an article's actual attached images end to end (upload images, then delete), since Vitest only ever supplies a mocked count.
  • Toast placement and stacking on a live app, including the three-toast cap — confirm against a running app, since Vitest only asserts useToast().add was called with the right arguments.
  • Real clipboard write on a live app — confirm navigator.clipboard.writeText actually places the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.