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,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 }
})