Files
RenewCMS/features/api_client_global_error_handling.qa.md

77 lines
5.8 KiB
Markdown

# QA: Typed API client and session store with global 401, unverified-403, 429 and offline handling
This is a composable/store-only slice with no new screen and no new `/v1` handler, so there is no
Go `httptest` procedure. Verification is entirely Vitest (`task test:web`), unit-testing
`web/src/composables/useApi.ts` and `web/src/stores/session.ts` directly, plus a small check that
the existing router guard suite still passes unmodified.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `global.fetch` per case (`vi.fn()`), install a Pinia instance (`createPinia()` /
`setActivePinia`) before each test, and import the real `router` singleton from `@/router` so
`request(...)`'s navigation calls are observable on `router.currentRoute`. No real backend, no
httptest server, no live SMTP.
1. **Successful request returns parsed JSON and leaves isOffline false** — mock fetch resolving
`{ ok: true, status: 200, json: async () => ({ id: 5 }) }`; call
`useApi().request('/v1/article/5')`; assert it resolves to `{ id: 5 }`, assert `fetch` was called
with `credentials: 'include'`, and assert the exported `isOffline` ref is `false`.
2. **401 mid-session redirects and clears the session** — seed the session store with
`session = { verified: true }`, `status = 'ready'`; navigate the router to `/article/12/edit`
first so `router.currentRoute.value.fullPath` matches; mock fetch resolving status 401; call
`useApi().request('/v1/article/12')`; assert the promise rejects with an `ApiError` whose
`status === 401`; assert `session.session === null` and `session.status === 'ready'`; assert
`router.currentRoute.value.fullPath` is
`/login?redirect=%2Farticle%2F12%2Fedit&reason=expired` (or the equivalent decoded query object).
3. **getMe's own 401 does not trigger the global redirect** — seed
`session = null, status = 'checking'`; mock fetch resolving status 401; record
`router.currentRoute.value.fullPath` before the call; call `useApi().getMe()`; assert it rejects
with `ApiError` status 401; assert the router's current route is unchanged from before the call.
4. **403 with a known-unverified session redirects to /register/pending** — seed
`session = { verified: false }, status = 'ready'`; mock fetch resolving status 403; call
`useApi().request('/v1/article/')`; assert the promise rejects with `ApiError` status 403; assert
`router.currentRoute.value.path === '/register/pending'`; assert
`session.session` is still `{ verified: false }`.
5. **403 with no session, or a verified session, passes through untouched** — two cases,
`session = null` and `session = { verified: true }`, both `status = 'ready'`; mock fetch resolving
status 403; navigate the router to a known path first; call `useApi().request(...)`; assert the
promise rejects with `ApiError` status 403; assert the router's current route did not change;
assert the session store's state object is unchanged (`toEqual` the pre-call snapshot).
6. **429 exposes retryAfter parsed from the Retry-After header** — mock fetch resolving
`{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }`; call
`useApi().request('/v1/auth/login', { method: 'POST' })`; assert the rejection is an `ApiError`
with `status === 429` and `retryAfter === 30`.
7. **429 with no Retry-After header exposes retryAfter as undefined** — same as above with no header
set; assert `retryAfter === undefined`.
8. **Network failure throws NetworkError, not ApiError, and sets isOffline true** — set
`Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })`; mock fetch to
`mockRejectedValue(new TypeError('Failed to fetch'))`; call `useApi().request('/v1/article/')`;
assert the rejection is instanceof `NetworkError` and `not instanceof ApiError`; assert `isOffline`
is `true` after the rejection settles.
9. **A later successful request clears isOffline** — starting from `isOffline === true` (reuse the
state from test 8, or set it directly if the module exposes a test seam), keep
`navigator.onLine === true`, mock fetch resolving 200; call `useApi().request('/v1/article/')`;
assert `isOffline` becomes `false` after the call resolves.
10. **window online/offline events drive isOffline directly** — dispatch
`window.dispatchEvent(new Event('offline'))`; assert `isOffline === true`; dispatch
`window.dispatchEvent(new Event('online'))`; assert `isOffline === false`. If `useApi`'s offline
tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on
first `useApi()` call or app mount), call `useApi()` once before dispatching the events.
11. **Regression: existing getMe/session/router-guard suites stay green** — run
`web/src/router/__tests__/routerGuard.spec.ts` (and any existing `useApi`/`session` spec files)
unmodified alongside the new tests in the same `task test:web` run; assert no existing test's
assertions changed and all still pass. This is not a new scenario to author, just a check that the
new request()/isOffline/clear() additions don't regress boot, session caching or the
public/session/verified/any redirect matrix already covered by
`features/nuxt_ui_shell_foundation.feature`.
## Manual verification
- **Real network offline test** — disable the machine's network adapter (or use browser devtools'
"Offline" throttling) against a `bun dev` instance, trigger a real authenticated call, and confirm
`isOffline` flips and a consuming banner-style UI would show, since Vitest only simulates a
rejected `fetch`, never a real dropped connection.
- **429 countdown UI** — this slice only proves `useApi` surfaces `retryAfter`; the actual countdown
display (`useRetryAfter`, per `docs/design/components.md`) belongs to the login and
password-reset screen slices and is out of scope here.