summaryrefslogtreecommitdiff
path: root/frontend/src/lib/api.test.ts
blob: b66d9cae02f4fe7a609846cd3d994e76c73cac96 (plain)
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');
  });
});