Files
2026-07-13 13:20:22 +08:00

117 lines
3.5 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for renderer/api/client.ts error handling.
* Regression tests for #3249: Response body double consumption on non-JSON errors.
*
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createApiClient, ApiError } from '@renderer/api/client';
describe('api/client', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('error handling', () => {
it('JSON error response is parsed into ApiError.body', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: 'invalid model' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
const api = createApiClient('http://127.0.0.1:9123');
try {
await api.get('/api/models');
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
const err = e as ApiError;
expect(err.status).toBe(400);
expect(err.body).toEqual({ error: 'invalid model' });
}
});
it('non-JSON error response captures raw text without double body consumption (#3249)', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response('Unauthorized', {
status: 401,
statusText: 'Unauthorized',
headers: { 'Content-Type': 'text/plain' },
})
);
vi.stubGlobal('fetch', fetchSpy);
const api = createApiClient('http://127.0.0.1:9123');
try {
await api.post('/api/models', { provider: 'deepseek' });
expect.fail('Should have thrown');
} catch (e) {
// Before the fix this threw TypeError "body stream already read" instead
expect(e).toBeInstanceOf(ApiError);
const err = e as ApiError;
expect(err.status).toBe(401);
expect(err.body).toBe('Unauthorized');
}
});
it('empty error body falls back to empty string', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 502 }));
vi.stubGlobal('fetch', fetchSpy);
const api = createApiClient('http://127.0.0.1:9123');
try {
await api.get('/api/models');
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
const err = e as ApiError;
expect(err.status).toBe(502);
expect(err.body).toBe('');
}
});
});
describe('success path', () => {
it('returns parsed JSON for application/json responses', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
const api = createApiClient('http://127.0.0.1:9123');
const result = await api.get<{ ok: boolean }>('/api/health');
expect(result).toEqual({ ok: true });
});
it('returns undefined for non-JSON responses', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchSpy);
const api = createApiClient('http://127.0.0.1:9123');
const result = await api.delete('/api/models/1');
expect(result).toBeUndefined();
});
});
});