import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockFetchProviderSnapshot = vi.fn(); const mockHostApiFetch = vi.fn(); vi.mock('@/lib/provider-accounts', () => ({ fetchProviderSnapshot: (...args: unknown[]) => mockFetchProviderSnapshot(...args), isHostApiRouteMissing: (value: unknown): boolean => { if (!value || typeof value !== 'object') return false; const record = value as Record; if (record.success !== false) return false; const error = record.error; return typeof error === 'string' && /no\s+route\s+for/i.test(error); }, })); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => mockHostApiFetch(...args), })); import { useProviderStore } from '@/stores/providers'; describe('useProviderStore – validateAccountApiKey()', () => { beforeEach(() => { vi.clearAllMocks(); }); it('trims API keys before sending provider validation requests', async () => { mockHostApiFetch.mockResolvedValueOnce({ valid: true }); const result = await useProviderStore.getState().validateAccountApiKey('custom', ' sk-lm-test \n', { baseUrl: 'http://127.0.0.1:1234/v1', apiProtocol: 'openai-completions', }); expect(result).toEqual({ valid: true }); expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/validate', { method: 'POST', body: JSON.stringify({ accountId: 'custom', vendorId: 'custom', providerId: 'custom', apiKey: 'sk-lm-test', options: { baseUrl: 'http://127.0.0.1:1234/v1', apiProtocol: 'openai-completions', }, }), }); }); it('falls back to legacy /api/providers/validate when the new route throws a 404', async () => { // The browser-fallback path of `hostApiFetch` (used in non-Electron // environments and surfaced by some IPC error normalisations) throws // on non-2xx HTTP. Make sure the renderer treats those as missing-route. mockHostApiFetch.mockRejectedValueOnce(new Error('404 Not Found')); mockHostApiFetch.mockResolvedValueOnce({ valid: true }); const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test', { baseUrl: 'http://127.0.0.1:1234/v1', }); expect(result).toEqual({ valid: true }); expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object)); expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/validate', { method: 'POST', body: JSON.stringify({ providerId: 'custom', apiKey: 'sk-lm-test', options: { baseUrl: 'http://127.0.0.1:1234/v1' }, }), }); }); it('falls back to legacy /api/providers/validate when the new route returns a route-not-found body', async () => { // The Electron IPC proxy never throws on HTTP 404 — it surfaces the // JSON body. Older Host API builds without the new validate route // therefore return `{ success: false, error: "No route for ..." }`. // The renderer must detect that body shape via `isHostApiRouteMissing` // and replay the request against the legacy route. This is the path // that actually runs in production today. mockHostApiFetch.mockResolvedValueOnce({ success: false, error: 'No route for POST /api/provider-accounts/validate', }); mockHostApiFetch.mockResolvedValueOnce({ valid: true }); const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test', { baseUrl: 'http://127.0.0.1:1234/v1', }); expect(result).toEqual({ valid: true }); expect(mockHostApiFetch).toHaveBeenCalledTimes(2); expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object)); expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/validate', { method: 'POST', body: JSON.stringify({ providerId: 'custom', apiKey: 'sk-lm-test', options: { baseUrl: 'http://127.0.0.1:1234/v1' }, }), }); }); it('does NOT fall back when the new route returns a real validation failure', async () => { // `{ valid: false, error: ... }` is a legitimate validation result — // it must NOT be confused with a missing-route body (whose discriminator // is `success: false`). Otherwise we would silently retry against the // legacy route and double-charge the upstream provider. mockHostApiFetch.mockResolvedValueOnce({ valid: false, error: 'API key is rejected' }); const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test'); expect(result).toEqual({ valid: false, error: 'API key is rejected' }); expect(mockHostApiFetch).toHaveBeenCalledTimes(1); expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object)); }); }); describe('useProviderStore – getAccountApiKey()', () => { beforeEach(() => { vi.clearAllMocks(); }); it('reads the key from the new account-namespaced endpoint by default', async () => { mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-stored-key' }); const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); expect(apiKey).toBe('sk-stored-key'); expect(mockHostApiFetch).toHaveBeenCalledTimes(1); expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/openai-account-1/api-key'); }); it('falls back to legacy /api/providers/:id/api-key when the new route throws a 404', async () => { // Browser-fallback path: thrown 404. mockHostApiFetch.mockRejectedValueOnce(new Error('404 Not Found')); mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-legacy-key' }); const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); expect(apiKey).toBe('sk-legacy-key'); expect(mockHostApiFetch).toHaveBeenCalledTimes(2); expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/openai-account-1/api-key'); expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/openai-account-1/api-key'); }); it('falls back to legacy /api/providers/:id/api-key when the new route returns a route-not-found body', async () => { // Electron IPC proxy path: 404 surfaces as a "No route" body. mockHostApiFetch.mockResolvedValueOnce({ success: false, error: 'No route for GET /api/provider-accounts/openai-account-1/api-key', }); mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-legacy-key' }); const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); expect(apiKey).toBe('sk-legacy-key'); expect(mockHostApiFetch).toHaveBeenCalledTimes(2); expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/openai-account-1/api-key'); expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/openai-account-1/api-key'); }); it('returns null when the legacy fallback also reports no key', async () => { mockHostApiFetch.mockResolvedValueOnce({ success: false, error: 'No route for GET /api/provider-accounts/missing-account/api-key', }); mockHostApiFetch.mockResolvedValueOnce({ apiKey: null }); const apiKey = await useProviderStore.getState().getAccountApiKey('missing-account'); expect(apiKey).toBeNull(); expect(mockHostApiFetch).toHaveBeenCalledTimes(2); }); it('encodes the account id so colons and slashes survive the request', async () => { mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-stored-key' }); await useProviderStore.getState().getAccountApiKey('vendor:weird/id'); expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/vendor%3Aweird%2Fid/api-key'); }); });