diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..386c650 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,17 @@ +import { request } from './client' +import type { User } from '@/stores/auth' + +export interface LoginResponse { + token: string + user: User +} + +export const authApi = { + login: (email: string, password: string) => + request('/api/v1/auth/login', { + method: 'POST', + body: { email, password }, + }), + me: () => request('/api/v1/auth/me'), + logout: () => request('/api/v1/auth/logout', { method: 'POST' }), +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..008c4e7 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,55 @@ +import { ApiException, type ApiError } from './types' + +const BASE = '' // 同源;dev 走 vite proxy + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' + body?: unknown + signal?: AbortSignal +} + +let authTokenProvider: () => string | null = () => null +let onUnauthorized: () => void = () => {} + +export function configure(opts: { + tokenProvider: () => string | null + onUnauthorized: () => void +}) { + authTokenProvider = opts.tokenProvider + onUnauthorized = opts.onUnauthorized +} + +export async function request(path: string, opts: RequestOptions = {}): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-Request-ID': crypto.randomUUID(), + } + const token = authTokenProvider() + if (token) headers['Authorization'] = `Bearer ${token}` + + const resp = await fetch(BASE + path, { + method: opts.method || 'GET', + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: opts.signal, + }) + + if (resp.status === 401) { + onUnauthorized() + } + + if (resp.status === 204) { + return undefined as T + } + + const text = await resp.text() + const data = text ? JSON.parse(text) : null + + if (!resp.ok) { + throw new ApiException( + resp.status, + (data as ApiError) || { code: 'unknown', message: resp.statusText }, + ) + } + return data as T +} diff --git a/web/src/api/notifications.ts b/web/src/api/notifications.ts new file mode 100644 index 0000000..1644d56 --- /dev/null +++ b/web/src/api/notifications.ts @@ -0,0 +1,15 @@ +import { request } from './client' +import type { Notification } from '@/stores/notifications' + +export const notificationsApi = { + list: (unreadOnly = false, limit = 50) => + request<{ items: Notification[] }>( + `/api/v1/notifications?unread=${unreadOnly}&limit=${limit}`, + ), + unreadCount: () => + request<{ count: number }>('/api/v1/notifications/unread-count'), + markRead: (id: string) => + request(`/api/v1/notifications/${id}/read`, { method: 'PATCH' }), + markAllRead: () => + request('/api/v1/notifications/read-all', { method: 'PATCH' }), +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..927c28e --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,17 @@ +export interface ApiError { + code: string + message: string + fields?: Record + request_id?: string +} + +export class ApiException extends Error { + status: number + body: ApiError + + constructor(status: number, body: ApiError) { + super(body.message || `HTTP ${status}`) + this.status = status + this.body = body + } +} diff --git a/web/test/api-client.test.ts b/web/test/api-client.test.ts new file mode 100644 index 0000000..9e8f07e --- /dev/null +++ b/web/test/api-client.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { configure, request } from '@/api/client' +import { ApiException } from '@/api/types' + +describe('api client', () => { + let unauthorizedCalled = 0 + + beforeEach(() => { + unauthorizedCalled = 0 + configure({ + tokenProvider: () => 'tok', + onUnauthorized: () => { + unauthorizedCalled++ + }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('attaches bearer token and parses JSON on 200', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ x: 1 }), { status: 200 }), + ) + const out = await request<{ x: number }>('/api/test') + expect(out.x).toBe(1) + const init = fetchMock.mock.calls[0][1] as RequestInit + const headers = init.headers as Record + expect(headers['Authorization']).toBe('Bearer tok') + expect(headers['X-Client-Request-ID']).toBeDefined() + }) + + it('returns undefined on 204', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) + const out = await request('/api/test', { method: 'DELETE' }) + expect(out).toBeUndefined() + }) + + it('invokes onUnauthorized for 401 and throws', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ code: 'unauthorized', message: 'no' }), { status: 401 }), + ) + await expect(request('/api/test')).rejects.toBeInstanceOf(ApiException) + expect(unauthorizedCalled).toBe(1) + }) + + it('throws ApiException with body for 4xx/5xx', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ code: 'invalid_input', message: 'bad' }), { status: 400 }), + ) + try { + await request('/api/test') + throw new Error('should have thrown') + } catch (e) { + const ex = e as ApiException + expect(ex.status).toBe(400) + expect(ex.body.code).toBe('invalid_input') + } + }) +}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..82afc15 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'happy-dom', + globals: true, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})