1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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');
});
});
|