77 lines
5.8 KiB
Markdown
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.
|