summaryrefslogtreecommitdiff
path: root/frontend/src/lib/api.test.ts
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/lib/api.test.ts
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/lib/api.test.ts')
-rw-r--r--frontend/src/lib/api.test.ts94
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');
+ });
+});