// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) // vi.mock is hoisted, so mockReplace must be inside the factory vi.mock('@/router', () => ({ default: { currentRoute: { value: { name: 'hermes.chat' } }, replace: vi.fn(), }, })) import { getApiKey, setApiKey, clearApiKey, hasApiKey, request } from '../../packages/client/src/api/client' import router from '@/router' describe('API Client', () => { beforeEach(() => { localStorage.clear() vi.clearAllMocks() }) describe('token management', () => { it('hasApiKey returns false when no token', () => { expect(hasApiKey()).toBe(false) }) it('hasApiKey returns true after setApiKey', () => { setApiKey('test-token') expect(hasApiKey()).toBe(true) }) it('getApiKey returns the stored token', () => { setApiKey('my-token') expect(getApiKey()).toBe('my-token') }) it('clearApiKey removes the token', () => { setApiKey('my-token') clearApiKey() expect(hasApiKey()).toBe(false) expect(getApiKey()).toBe('') }) }) describe('request', () => { it('adds Authorization header when token exists', async () => { setApiKey('secret-key') mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) await request('/api/hermes/sessions') expect(mockFetch).toHaveBeenCalledOnce() const [, options] = mockFetch.mock.calls[0] expect(options.headers.Authorization).toBe('Bearer secret-key') }) it('does not add Authorization header when no token', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) await request('/api/hermes/sessions') const [, options] = mockFetch.mock.calls[0] expect(options.headers.Authorization).toBeUndefined() }) it('clears token and redirects on 401 for local BFF endpoints', async () => { setApiKey('secret-key') mockFetch.mockResolvedValue({ ok: false, status: 401 }) await expect(request('/api/hermes/sessions')).rejects.toThrow('Unauthorized') expect(hasApiKey()).toBe(false) expect(router.replace).toHaveBeenCalledWith({ name: 'login' }) }) it('does NOT clear token on 401 for proxied v1 endpoints', async () => { setApiKey('secret-key') mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) await expect(request('/api/hermes/v1/runs')).rejects.toThrow('API Error 401') expect(hasApiKey()).toBe(true) }) it('does NOT clear token on 401 for proxied jobs endpoints', async () => { setApiKey('secret-key') mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) await expect(request('/api/hermes/jobs')).rejects.toThrow('API Error 401') expect(hasApiKey()).toBe(true) }) it('does NOT clear token on 401 for proxied skills endpoints', async () => { setApiKey('secret-key') mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) await expect(request('/api/hermes/skills')).rejects.toThrow('API Error 401') expect(hasApiKey()).toBe(true) }) it('throws error on non-401 failure', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('Internal Server Error'), }) await expect(request('/api/hermes/sessions')).rejects.toThrow('API Error 500: Internal Server Error') }) it('returns parsed JSON on success', async () => { const data = { sessions: [{ id: '1' }] } mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(data) }) const result = await request('/api/hermes/sessions') expect(result).toEqual(data) }) }) })