5.8 KiB
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.
- Successful request returns parsed JSON and leaves isOffline false — mock fetch resolving
{ ok: true, status: 200, json: async () => ({ id: 5 }) }; calluseApi().request('/v1/article/5'); assert it resolves to{ id: 5 }, assertfetchwas called withcredentials: 'include', and assert the exportedisOfflineref isfalse. - 401 mid-session redirects and clears the session — seed the session store with
session = { verified: true },status = 'ready'; navigate the router to/article/12/editfirst sorouter.currentRoute.value.fullPathmatches; mock fetch resolving status 401; calluseApi().request('/v1/article/12'); assert the promise rejects with anApiErrorwhosestatus === 401; assertsession.session === nullandsession.status === 'ready'; assertrouter.currentRoute.value.fullPathis/login?redirect=%2Farticle%2F12%2Fedit&reason=expired(or the equivalent decoded query object). - getMe's own 401 does not trigger the global redirect — seed
session = null, status = 'checking'; mock fetch resolving status 401; recordrouter.currentRoute.value.fullPathbefore the call; calluseApi().getMe(); assert it rejects withApiErrorstatus 401; assert the router's current route is unchanged from before the call. - 403 with a known-unverified session redirects to /register/pending — seed
session = { verified: false }, status = 'ready'; mock fetch resolving status 403; calluseApi().request('/v1/article/'); assert the promise rejects withApiErrorstatus 403; assertrouter.currentRoute.value.path === '/register/pending'; assertsession.sessionis still{ verified: false }. - 403 with no session, or a verified session, passes through untouched — two cases,
session = nullandsession = { verified: true }, bothstatus = 'ready'; mock fetch resolving status 403; navigate the router to a known path first; calluseApi().request(...); assert the promise rejects withApiErrorstatus 403; assert the router's current route did not change; assert the session store's state object is unchanged (toEqualthe pre-call snapshot). - 429 exposes retryAfter parsed from the Retry-After header — mock fetch resolving
{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }; calluseApi().request('/v1/auth/login', { method: 'POST' }); assert the rejection is anApiErrorwithstatus === 429andretryAfter === 30. - 429 with no Retry-After header exposes retryAfter as undefined — same as above with no header
set; assert
retryAfter === undefined. - Network failure throws NetworkError, not ApiError, and sets isOffline true — set
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); mock fetch tomockRejectedValue(new TypeError('Failed to fetch')); calluseApi().request('/v1/article/'); assert the rejection is instanceofNetworkErrorandnot instanceof ApiError; assertisOfflineistrueafter the rejection settles. - 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), keepnavigator.onLine === true, mock fetch resolving 200; calluseApi().request('/v1/article/'); assertisOfflinebecomesfalseafter the call resolves. - window online/offline events drive isOffline directly — dispatch
window.dispatchEvent(new Event('offline')); assertisOffline === true; dispatchwindow.dispatchEvent(new Event('online')); assertisOffline === false. IfuseApi's offline tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on firstuseApi()call or app mount), calluseApi()once before dispatching the events. - Regression: existing getMe/session/router-guard suites stay green — run
web/src/router/__tests__/routerGuard.spec.ts(and any existinguseApi/sessionspec files) unmodified alongside the new tests in the sametask test:webrun; 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 byfeatures/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 devinstance, trigger a real authenticated call, and confirmisOfflineflips and a consuming banner-style UI would show, since Vitest only simulates a rejectedfetch, never a real dropped connection. - 429 countdown UI — this slice only proves
useApisurfacesretryAfter; the actual countdown display (useRetryAfter, perdocs/design/components.md) belongs to the login and password-reset screen slices and is out of scope here.