Files
RenewCMS/features/api_client_global_error_handling.qa.md

5.8 KiB

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.