/** * DeepSeekAdapter.getBalance 解析测试(v0.5.2 回归修复) * * 背景:DeepSeek 官方 /user/balance 实际返回 balance_infos 数组格式, * 此前按扁平字段解析(data.total_balance)→ 恒为 undefined → 界面永远显示 0。 * 本测试固化两种格式(官方数组 + 网关扁平)的解析与 URL 规范化行为。 */ import { describe, it, expect, vi, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { DeepSeekAdapter } from '../deepseek.adapter'; const CONFIG = { provider: 'deepseek', baseURL: 'https://api.deepseek.com', apiKey: 'sk-test-key', defaultModel: 'deepseek-v4-pro', }; function makeAdapter(baseURL = CONFIG.baseURL): DeepSeekAdapter { return new DeepSeekAdapter({ ...CONFIG, baseURL }); } const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); afterEach(() => { mockFetch.mockReset(); }); function okResponse(body: unknown): Response { return { ok: true, status: 200, json: async () => body, } as unknown as Response; } describe('DeepSeekAdapter.getBalance — 响应格式解析(v0.5.2)', () => { it('官方 balance_infos 数组格式正确解析(真实 API 格式,修复点)', async () => { mockFetch.mockResolvedValue( okResponse({ is_available: true, balance_infos: [ { currency: 'CNY', total_balance: '110.55', granted_balance: '10.55', topped_up_balance: '100.00', }, ], }), ); const balance = await makeAdapter().getBalance(); expect(balance).not.toBeNull(); expect(balance!.totalBalance).toBe('110.55'); expect(balance!.grantedBalance).toBe('10.55'); expect(balance!.toppedUpBalance).toBe('100.00'); expect(balance!.currency).toBe('CNY'); }); it('扁平格式回退解析(网关/代理简化响应兼容)', async () => { mockFetch.mockResolvedValue( okResponse({ currency: 'USD', total_balance: '50.00', granted_balance: '0.00', topped_up_balance: '50.00', }), ); const balance = await makeAdapter().getBalance(); expect(balance).not.toBeNull(); expect(balance!.totalBalance).toBe('50.00'); expect(balance!.currency).toBe('USD'); }); it('baseURL 带尾斜杠或 /v1 前缀时规范化为根路径端点', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [{ currency: 'CNY', total_balance: '1.00' }], }), ); await makeAdapter('https://api.deepseek.com/v1/').getBalance(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.deepseek.com/user/balance', expect.objectContaining({ headers: { Authorization: 'Bearer sk-test-key' } }), ); mockFetch.mockClear(); await makeAdapter('https://api.deepseek.com/v1').getBalance(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.deepseek.com/user/balance', expect.anything(), ); }); it('非 2xx 响应返回 null', async () => { mockFetch.mockResolvedValue({ ok: false, status: 401 } as unknown as Response); expect(await makeAdapter().getBalance()).toBeNull(); }); it('请求异常返回 null(网络错误)', async () => { mockFetch.mockRejectedValue(new Error('network down')); expect(await makeAdapter().getBalance()).toBeNull(); }); it('balance_infos 多币种时取第一项', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [ { currency: 'CNY', total_balance: '100.00' }, { currency: 'USD', total_balance: '13.00' }, ], }), ); const balance = await makeAdapter().getBalance(); expect(balance!.currency).toBe('CNY'); expect(balance!.totalBalance).toBe('100.00'); }); it('balance_infos 为空数组 → 回退扁平字段(无第一项可取)', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [], currency: 'EUR', total_balance: '9.99' }), ); const balance = await makeAdapter().getBalance(); expect(balance!.currency).toBe('EUR'); expect(balance!.totalBalance).toBe('9.99'); }); it('balance_infos 首项字段缺失 → 默认 CNY / 0 兜底', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{}] })); const balance = await makeAdapter().getBalance(); expect(balance).toEqual({ currency: 'CNY', totalBalance: '0', grantedBalance: '0', toppedUpBalance: '0', }); }); it('is_available=false 仍返回余额(可用性由上层决定,解析不短路)', async () => { mockFetch.mockResolvedValue( okResponse({ is_available: false, balance_infos: [{ currency: 'CNY', total_balance: '0.5' }], }), ); const balance = await makeAdapter().getBalance(); expect(balance!.totalBalance).toBe('0.5'); }); it('json 解析失败(非法响应体)→ 返回 null', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => { throw new Error('Unexpected token'); }, } as unknown as Response); expect(await makeAdapter().getBalance()).toBeNull(); }); it('带 /v1 且带尾斜杠的 baseURL 规范化为根路径', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter('https://api.deepseek.com/v1/').getBalance(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.deepseek.com/user/balance', expect.anything(), ); }); it('多级尾斜杠 baseURL 规范化为根路径', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter('https://api.deepseek.com///').getBalance(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.deepseek.com/user/balance', expect.anything(), ); }); it('无 /v1 前缀的 baseURL 原样拼接(不重复插入)', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter('https://api.deepseek.com').getBalance(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.deepseek.com/user/balance', expect.anything(), ); }); it('子路径 baseURL(非 /v1 结尾)保留子路径', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter('https://proxy.example.com/gw/v2').getBalance(); expect(mockFetch).toHaveBeenCalledWith( 'https://proxy.example.com/gw/v2/user/balance', expect.anything(), ); }); it('请求头携带 Bearer apiKey', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter().getBalance(); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; expect((init.headers as Record).Authorization).toBe('Bearer sk-test-key'); }); it('网络异常与解析异常不抛出,统一返回 null(调用方安全)', async () => { mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); expect(await makeAdapter().getBalance()).toBeNull(); mockFetch.mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json'); }, } as unknown as Response); expect(await makeAdapter().getBalance()).toBeNull(); }); it('currency 保留字符串原样(不做数值转换)', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [{ currency: 'CNY', total_balance: '110.55' }] }), ); const balance = await makeAdapter().getBalance(); expect(balance!.currency).toBe('CNY'); expect(typeof balance!.totalBalance).toBe('string'); }); it('请求 URL 不含 /v1 时余额端点不受影响(契约回归)', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter('https://api.deepseek.com/v1').getBalance(); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).not.toContain('/v1'); expect(calledUrl).toBe('https://api.deepseek.com/user/balance'); }); it('HTTP 429 返回 null(限流不视为余额数据)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 429 } as unknown as Response); expect(await makeAdapter().getBalance()).toBeNull(); }); it('HTTP 500 返回 null', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 } as unknown as Response); expect(await makeAdapter().getBalance()).toBeNull(); }); it('HTTP 403 返回 null(鉴权失败)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 403 } as unknown as Response); expect(await makeAdapter().getBalance()).toBeNull(); }); it('空 apiKey 时发送空 Bearer 头(v0.7.4 修正命名 —— 原"不含 Authorization"与实际断言相反)', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await makeAdapter().getBalance(); // 适配器配置始终携带 apiKey;此处验证 apiKey 为空串时头部仍带 'Bearer '(空值) const noKeyAdapter = new DeepSeekAdapter({ ...CONFIG, apiKey: '' }); mockFetch.mockClear(); mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); await noKeyAdapter.getBalance(); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; expect((init.headers as Record).Authorization).toBe('Bearer '); }); it('数字型 total_balance(非字符串)原样透传', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [{ currency: 'CNY', total_balance: 88 }] }), ); const balance = await makeAdapter().getBalance(); expect(balance!.totalBalance).toBe(88); }); it('响应为空对象 → 全部默认值', async () => { mockFetch.mockResolvedValue(okResponse({})); const balance = await makeAdapter().getBalance(); expect(balance).toEqual({ currency: 'CNY', totalBalance: '0', grantedBalance: '0', toppedUpBalance: '0', }); }); it('granted_balance 缺失仅缺省为 0(不影响其余字段)', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [{ currency: 'USD', total_balance: '5.0', topped_up_balance: '5.0' }], }), ); const balance = await makeAdapter().getBalance(); expect(balance!.grantedBalance).toBe('0'); expect(balance!.totalBalance).toBe('5.0'); }); it('balance_infos 元素为 null → 回退扁平字段(防御空条目)', async () => { mockFetch.mockResolvedValue( okResponse({ balance_infos: [null], currency: 'JPY', total_balance: '1000' }), ); const balance = await makeAdapter().getBalance(); expect(balance!.currency).toBe('JPY'); expect(balance!.totalBalance).toBe('1000'); }); it('多次调用互不影响(每次独立 fetch)', async () => { mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] })); const adapter = makeAdapter(); await adapter.getBalance(); await adapter.getBalance(); expect(mockFetch).toHaveBeenCalledTimes(2); }); });