diff options
Diffstat (limited to 'frontend/src/lib/api.test.ts')
| -rw-r--r-- | frontend/src/lib/api.test.ts | 94 |
1 files changed, 94 insertions, 0 deletions
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..b66d9ca --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { apiFetch, ApiError } from './api'; +import type { ApiErrorBody } from './api'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('apiFetch', () => { + it('parses JSON success responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } })) + ); + const res = await apiFetch<void, { ok: number }>('/api/health'); + expect(res).toEqual({ ok: 1 }); + }); + + it('returns undefined for empty body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })) + ); + const res = await apiFetch<void, undefined>('/api/auth/logout', { method: 'POST' }); + expect(res).toBeUndefined(); + }); + + it('raises ApiError with structured error body', async () => { + const body: ApiErrorBody = { + error: { code: 'stale_generation', message: '草稿已被其他修改更新', details: { expected: 'g1' } } + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(body), { status: 409, headers: { 'content-type': 'application/json' } })) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(409); + expect(err.code).toBe('stale_generation'); + expect(err.message).toBe('草稿已被其他修改更新'); + expect(err.details).toEqual({ expected: 'g1' }); + }); + + it('raises ApiError for non-JSON error body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('oops', { status: 500 })) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(500); + expect(err.code).toBe('server_error'); + }); + + it('raises network ApiError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('failed'); + }) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(0); + expect(err.code).toBe('network'); + }); + + it('sends JSON content type for body requests and does not for bodyless', async () => { + const f = vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', f); + await apiFetch('/api/projects', { method: 'POST', body: { name: 'x' } }); + expect(f).toHaveBeenCalledWith( + '/api/projects', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'content-type': 'application/json' }), + credentials: 'same-origin' + }) + ); + await apiFetch('/api/projects'); + expect(f).toHaveBeenLastCalledWith( + '/api/projects', + expect.not.objectContaining({ headers: expect.objectContaining({ 'content-type': 'application/json' }) }) + ); + }); + + it('rejects a 200 without JSON body per API contract', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('<html>oops</html>', { status: 200, headers: { 'content-type': 'text/html' } })) + ); + const err = await apiFetch<void>('/api/projects').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.code).toBe('bad_response'); + }); +}); |
