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:
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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in new issue
Block a user