feat: typed API client (useApi) and session store (useSession) with the global 401, unverified-403, 429 and offline handling from states.md

This commit is contained in:
fsylvain committed 2026-09-25 17:33:40 +02:00
1 parent eed661bb24
commit 12faab7f92
6 files changed
+488 -6

No files matched your search

@@ -0,0 +1,121 @@
Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling
As the maintainer of RenewCMS
I want useApi to expose one generic authenticated request method that every future screen call
site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason
and clears the session, turns a 403 from a known-unverified session into a redirect to
/register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown,
and marks the app offline on a network failure
So that later screen slices (login, articles, editor, ...) get consistent global session and
connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc
Background:
Given useApi exports a generic request(path, options) method, used by getMe and by every future
endpoint call, that always sends credentials: 'include' and parses a JSON response body on
success
And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429
response also carries retryAfter, the integer number of seconds parsed from the response's
Retry-After header, or undefined if the header is absent or not a plain integer
And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never
reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers
can tell "the server answered with an error" apart from "the request never got an answer" with
instanceof
And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and
every component observes the same value: a NetworkError sets it true; it is also true whenever
navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while
navigator.onLine is true sets it back to false
And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one
call still throws a plain ApiError with status 401, exactly as before, and does not trigger the
redirect below, because this call only ever runs as part of session.check() during router
navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already
redirects to /login?redirect=<target>&reason=expired for a "no session" boot result
And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401,
before the ApiError reaches the caller, useApi calls the session store's clear() action (session
= null, status stays 'ready') and navigates the router to
/login?redirect=<currentPath>&reason=expired, where <currentPath> is
router.currentRoute.value.fullPath at the moment the 401 is received
And on a 403, useApi reads the session store: if it holds a session whose verified is false, this
is the global "unverified account" case from docs/design/routes.md and states.md, so useApi
navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403,
and the session store is left untouched, since it already correctly records verified: false; any
other 403 (no session, or a session whose verified is true) is not the global case: useApi does
nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret
(wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset
link, and registration-closed are all screen-level, out of scope here)
And the session store gains a clear() action (session = null, status = 'ready', independent of
check()/retry()) used by the global 401 handling above
Scenario: A successful authenticated request returns the parsed body
Given a mocked fetch that resolves 200 with JSON body {"id": 5}
When useApi().request('/v1/article/5') is called
Then the returned value is {"id": 5}
And isOffline is false
Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session
Given the session store holds session {"verified": true} and status "ready"
And the current route is /article/12/edit
And a mocked fetch that resolves 401
When useApi().request('/v1/article/12') is called
Then the call rejects with an ApiError whose status is 401
And the session store's session becomes null and its status stays "ready"
And the router navigates to "/login?redirect=/article/12/edit&reason=expired"
Scenario: The boot call to getMe does not trigger the global 401 redirect
Given the session store holds session null and status "checking"
And a mocked fetch that resolves 401
When useApi().getMe() is called directly, outside of router navigation
Then the call rejects with an ApiError whose status is 401
And the router does not navigate anywhere
Scenario: A 403 while the session is known to be unverified redirects to the pending page
Given the session store holds session {"verified": false} and status "ready"
And a mocked fetch that resolves 403
When useApi().request('/v1/article/') is called
Then the call rejects with an ApiError whose status is 403
And the router navigates to "/register/pending"
And the session store's session is still {"verified": false}
Scenario Outline: A 403 that is not the global unverified case passes through untouched
Given the session store holds session <session> and status "ready"
And a mocked fetch that resolves 403
When useApi().request('/v1/some/endpoint') is called
Then the call rejects with an ApiError whose status is 403
And the router does not navigate anywhere
And the session store is unchanged
Examples:
| session |
| null |
| {"verified": true} |
Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds
Given a mocked fetch that resolves 429 with a Retry-After header of "30"
When useApi().request('/v1/auth/login', { method: 'POST' }) is called
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30
Scenario: A 429 with no Retry-After header exposes an undefined retryAfter
Given a mocked fetch that resolves 429 with no Retry-After header
When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined
Scenario: A network failure throws a NetworkError and marks the app offline
Given navigator.onLine is true
And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is
unreachable
When useApi().request('/v1/article/') is called
Then the call rejects with a NetworkError, not an ApiError
And isOffline becomes true
Scenario: A later successful request clears the offline flag
Given isOffline is true from a previous network failure
And navigator.onLine is true
And a mocked fetch that resolves 200 with JSON body {}
When useApi().request('/v1/article/') is called
Then isOffline becomes false
Scenario: The browser's own online/offline events also drive the shared flag
Given isOffline is false
When the browser fires a "offline" event on window
Then isOffline becomes true
When the browser fires a "online" event on window
Then isOffline becomes false
@@ -0,0 +1,76 @@
# 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.
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
async function settle() {
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 20))
}
const fetchMock = vi.fn()
beforeEach(() => {
setActivePinia(createPinia())
fetchMock.mockReset()
global.fetch = fetchMock as unknown as typeof fetch
setOnline(true)
isOffline.value = false
})
afterEach(async () => {
await router.push('/login')
})
describe('useApi global 401/403/429/offline handling', () => {
it('A successful authenticated request returns the parsed body', async () => {
fetchMock.mockResolvedValue(mockResponse(200, { id: 5 }))
const result = await useApi().request('/v1/article/5')
expect(result).toEqual({ id: 5 })
expect(isOffline.value).toBe(false)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/article/5',
expect.objectContaining({ credentials: 'include' }),
)
})
it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => {
const session = useSessionStore()
session.session = { verified: true }
session.status = 'ready'
await router.push('/article/12/edit')
fetchMock.mockResolvedValue(mockResponse(401))
const call = useApi().request('/v1/article/12')
await expectApiError(call, { status: 401 })
expect(session.session).toBeNull()
expect(session.status).toBe('ready')
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/article/12/edit&reason=expired',
),
)
})
it('The boot call to getMe does not trigger the global 401 redirect', async () => {
const session = useSessionStore()
session.session = null
session.status = 'checking'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(401))
const call = useApi().getMe()
await expectApiError(call, { status: 401 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
})
it('A 403 while the session is known to be unverified redirects to the pending page', async () => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/article/')
await expectApiError(call, { status: 403 })
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(session.session).toEqual({ verified: false })
})
it.each([
{ name: 'null', session: null },
{ name: 'verified true', session: { verified: true } },
])(
'A 403 that is not the global unverified case passes through untouched ($name)',
async ({ session: sessionValue }) => {
const session = useSessionStore()
session.session = sessionValue
session.status = 'ready'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/some/endpoint')
await expectApiError(call, { status: 403 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
expect(session.session).toEqual(sessionValue)
},
)
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
const call = useApi().request('/v1/auth/login', { method: 'POST' })
await expectApiError(call, { status: 429, retryAfter: 30 })
})
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
fetchMock.mockResolvedValue(mockResponse(429))
const call = useApi().request('/v1/auth/password-reset', { method: 'POST' })
await expectApiError(call, { status: 429, retryAfter: undefined })
})
it('A network failure throws a NetworkError and marks the app offline', async () => {
setOnline(true)
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
const call = useApi().request('/v1/article/')
await expect(call).rejects.toBeInstanceOf(NetworkError)
await expect(call).rejects.not.toBeInstanceOf(ApiError)
expect(isOffline.value).toBe(true)
})
it('A later successful request clears the offline flag', async () => {
isOffline.value = true
setOnline(true)
fetchMock.mockResolvedValue(mockResponse(200, {}))
await useApi().request('/v1/article/')
expect(isOffline.value).toBe(false)
})
it("The browser's own online/offline events also drive the shared flag", () => {
isOffline.value = false
window.dispatchEvent(new Event('offline'))
expect(isOffline.value).toBe(true)
window.dispatchEvent(new Event('online'))
expect(isOffline.value).toBe(false)
})
})
+87 -5
View File
@@ -1,9 +1,21 @@
import { ref } from 'vue'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
export class ApiError extends Error {
status: number
retryAfter?: number
constructor(status: number) {
constructor(status: number, retryAfter?: number) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
}
}
export class NetworkError extends Error {
constructor() {
super('Network request failed')
}
}
@@ -11,15 +23,85 @@ export interface MeResult {
verified: boolean
}
export type OnUnauthorized = 'redirect' | 'passthrough'
export interface RequestOptions extends RequestInit {
onUnauthorized?: OnUnauthorized
}
export const isOffline = ref(false)
function isNavigatorOnLine() {
return typeof navigator === 'undefined' || navigator.onLine !== false
}
function syncOfflineFlagWithNavigator() {
isOffline.value = !isNavigatorOnLine()
}
function parseRetryAfterSeconds(response: Response): number | undefined {
const header = response.headers.get('Retry-After')
if (header === null || !/^\d+$/.test(header)) {
return undefined
}
return Number.parseInt(header, 10)
}
if (typeof window !== 'undefined') {
window.addEventListener('offline', () => {
isOffline.value = true
})
window.addEventListener('online', () => {
isOffline.value = false
})
}
export function useApi() {
async function getMe(): Promise<MeResult> {
const response = await fetch('/v1/auth/me', { credentials: 'include' })
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', ...init } = options
let response: Response
try {
response = await fetch(path, { credentials: 'include', ...init })
} catch {
isOffline.value = true
throw new NetworkError()
}
syncOfflineFlagWithNavigator()
if (!response.ok) {
if (response.status === 401 && onUnauthorized === 'redirect') {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
router.push({ path: '/login', query: { redirect: currentPath, reason: 'expired' } })
throw new ApiError(401)
}
if (response.status === 403) {
const session = useSessionStore()
if (session.session?.verified === false) {
router.push('/register/pending')
}
throw new ApiError(403)
}
if (response.status === 429) {
throw new ApiError(429, parseRetryAfterSeconds(response))
}
throw new ApiError(response.status)
}
const body = await response.json()
return response.json()
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
return { verified: Boolean(body.is_verified) }
}
return { getMe }
return { getMe, request }
}
+20
View File
@@ -0,0 +1,20 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('session store clear action', () => {
it('resets the session to null and the status to ready regardless of the previous state', () => {
const session = useSessionStore()
session.session = { verified: true }
session.status = 'checking'
session.clear()
expect(session.session).toBeNull()
expect(session.status).toBe('ready')
})
})
+6 -1
View File
@@ -46,5 +46,10 @@ export const useSessionStore = defineStore('session', () => {
return check()
}
return { status, session, check, retry }
function clear() {
session.value = null
status.value = 'ready'
}
return { status, session, check, retry, clear }
})