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('/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('/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('/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('/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('/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('oops', { status: 200, headers: { 'content-type': 'text/html' } })) ); const err = await apiFetch('/api/projects').catch((e) => e); expect(err).toBeInstanceOf(ApiError); expect(err.code).toBe('bad_response'); }); });