117 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|