diff --git a/package.json b/package.json index 2444eba..248c7f0 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,10 @@ "dev:client": "vite --host", "dev:server": "nodemon --signal SIGTERM --watch packages/server/src -e ts,tsx --exec node -r ts-node/register packages/server/src/index.ts", "build": "vue-tsc -b && vite build && tsc -p packages/server/tsconfig.json", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "files": [ "bin/", @@ -65,6 +68,7 @@ "ws": "^8.20.0" }, "devDependencies": { + "@pinia/testing": "^1.0.3", "@types/js-yaml": "^4.0.9", "@types/koa": "^2.15.0", "@types/koa__cors": "^5.0.0", @@ -76,13 +80,16 @@ "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@vitejs/plugin-vue": "^6.0.5", + "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.9.1", "concurrently": "^9.2.1", + "jsdom": "^27.0.1", "nodemon": "^3.1.14", "sass": "^1.99.0", "ts-node": "^10.9.2", "typescript": "~6.0.2", "vite": "^8.0.4", + "vitest": "^3.2.4", "vue-tsc": "^3.2.6" } -} \ No newline at end of file +} diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts index d55adab..a94bbcf 100644 --- a/packages/client/src/api/client.ts +++ b/packages/client/src/api/client.ts @@ -41,8 +41,13 @@ export async function request(path: string, options: RequestInit = {}): Promi const res = await fetch(url, { ...options, headers }) - // Global 401 handler — clear auth and redirect to login - if (res.status === 401) { + // Global 401 handler — only redirect to login for local BFF endpoints + // Proxied gateway requests should not trigger logout + const isLocalBff = !path.startsWith('/api/hermes/v1/') && + !path.startsWith('/api/hermes/jobs') && + !path.startsWith('/api/hermes/skills') + + if (res.status === 401 && isLocalBff) { clearApiKey() if (router.currentRoute.value.name !== 'login') { router.replace({ name: 'login' }) diff --git a/packages/client/src/assets/hero.png b/packages/client/src/assets/hero.png deleted file mode 100644 index cc51a3d..0000000 Binary files a/packages/client/src/assets/hero.png and /dev/null differ diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index b5f2918..9ec2754 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -8,27 +8,22 @@ export async function proxy(ctx: Context) { // /api/hermes/* -> /api/* (upstream uses /api/ prefix) const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api') const url = `${upstream}${upstreamPath}${ctx.search || ''}` - console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`) - // Build headers — forward most, strip browser-specific ones + // Build headers — forward most, strip browser/web-ui specific ones const headers: Record = {} for (const [key, value] of Object.entries(ctx.headers)) { if (value == null) continue const lower = key.toLowerCase() if (lower === 'host') { headers['host'] = new URL(upstream).host - } else if (lower !== 'origin' && lower !== 'referer' && lower !== 'connection') { + } else if (lower === 'authorization' || lower === 'origin' || lower === 'referer' || lower === 'connection') { + continue + } else { const v = Array.isArray(value) ? value[0] : value if (v) headers[key] = v } } - // Add SSE-friendly headers - if (ctx.path.match(/\/events$/)) { - headers['x-accel-buffering'] = 'no' - headers['cache-control'] = 'no-cache' - } - try { // Build request body from raw body let body: string | undefined @@ -43,20 +38,14 @@ export async function proxy(ctx: Context) { }) // Set response headers - const resHeaders: Record = {} res.headers.forEach((value, key) => { const lower = key.toLowerCase() if (lower !== 'transfer-encoding' && lower !== 'connection') { - resHeaders[key] = value + ctx.set(key, value) } }) - if (ctx.path.match(/\/events$/)) { - resHeaders['x-accel-buffering'] = 'no' - resHeaders['cache-control'] = 'no-cache' - } ctx.status = res.status - ctx.set(resHeaders) // Stream response body if (res.body) { diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts new file mode 100644 index 0000000..e9be2db --- /dev/null +++ b/tests/client/api.test.ts @@ -0,0 +1,119 @@ +// @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) + }) + }) +}) diff --git a/tests/client/profiles-store.test.ts b/tests/client/profiles-store.test.ts new file mode 100644 index 0000000..5ddc460 --- /dev/null +++ b/tests/client/profiles-store.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +const mockProfilesApi = vi.hoisted(() => ({ + fetchProfiles: vi.fn(), + fetchProfileDetail: vi.fn(), + createProfile: vi.fn(), + deleteProfile: vi.fn(), + renameProfile: vi.fn(), + switchProfile: vi.fn(), + exportProfile: vi.fn(), + importProfile: vi.fn(), +})) + +vi.mock('@/api/hermes/profiles', () => mockProfilesApi) + +import { useProfilesStore } from '@/stores/hermes/profiles' + +describe('Profiles Store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('fetchProfiles loads profiles and sets active', async () => { + const profiles = [ + { name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' }, + { name: 'dev', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' }, + ] + mockProfilesApi.fetchProfiles.mockResolvedValue(profiles) + + const store = useProfilesStore() + await store.fetchProfiles() + + expect(store.profiles).toEqual(profiles) + expect(store.activeProfile?.name).toBe('default') + expect(store.loading).toBe(false) + }) + + it('fetchProfiles sets loading state', async () => { + mockProfilesApi.fetchProfiles.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve([]), 10)) + ) + + const store = useProfilesStore() + const fetchPromise = store.fetchProfiles() + + expect(store.loading).toBe(true) + await fetchPromise + expect(store.loading).toBe(false) + }) + + it('createProfile calls API and refreshes list', async () => { + mockProfilesApi.createProfile.mockResolvedValue(true) + mockProfilesApi.fetchProfiles.mockResolvedValue([ + { name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' }, + { name: 'new-profile', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' }, + ]) + + const store = useProfilesStore() + const ok = await store.createProfile('new-profile', false) + + expect(ok).toBe(true) + expect(mockProfilesApi.createProfile).toHaveBeenCalledWith('new-profile', false) + expect(store.profiles).toHaveLength(2) + }) + + it('deleteProfile clears detail cache', async () => { + mockProfilesApi.deleteProfile.mockResolvedValue(true) + mockProfilesApi.fetchProfiles.mockResolvedValue([ + { name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' }, + ]) + + const store = useProfilesStore() + store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', gateway: '', skills: 0, hasEnv: false, hasSoulMd: false } + + await store.deleteProfile('test') + + expect(store.detailMap['test']).toBeUndefined() + }) + + it('fetchProfileDetail uses cache', async () => { + const detail = { name: 'cached', path: '/tmp/cached', model: 'gpt-4', provider: 'openai', gateway: 'running', skills: 5, hasEnv: true, hasSoulMd: false } + const store = useProfilesStore() + store.detailMap['cached'] = detail + + const result = await store.fetchProfileDetail('cached') + + expect(result).toEqual(detail) + expect(mockProfilesApi.fetchProfileDetail).not.toHaveBeenCalled() + }) + + it('switchProfile sets switching state', async () => { + mockProfilesApi.switchProfile.mockResolvedValue(true) + mockProfilesApi.fetchProfiles.mockResolvedValue([]) + + const store = useProfilesStore() + const switchPromise = store.switchProfile('dev') + + expect(store.switching).toBe(true) + await switchPromise + expect(store.switching).toBe(false) + }) +}) diff --git a/tests/server/auth.test.ts b/tests/server/auth.test.ts new file mode 100644 index 0000000..9056a3f --- /dev/null +++ b/tests/server/auth.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})) + +// Mock config +vi.mock('../../packages/server/src/config', () => ({ + config: { dataDir: '/tmp/hermes-test-data' }, +})) + +import { readFile, writeFile } from 'fs/promises' +import { getToken, authMiddleware } from '../../packages/server/src/services/auth' + +const mockedReadFile = vi.mocked(readFile) +const mockedWriteFile = vi.mocked(writeFile) + +describe('Auth Service', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + vi.clearAllMocks() + }) + + afterAll(() => { + process.env = originalEnv + }) + + describe('getToken', () => { + it('returns null when AUTH_DISABLED=1', async () => { + process.env.AUTH_DISABLED = '1' + + const token = await getToken() + + expect(token).toBeNull() + expect(mockedReadFile).not.toHaveBeenCalled() + }) + + it('returns null when AUTH_DISABLED=true', async () => { + process.env.AUTH_DISABLED = 'true' + + const token = await getToken() + + expect(token).toBeNull() + }) + + it('returns AUTH_TOKEN env var if set', async () => { + process.env.AUTH_TOKEN = 'my-custom-token' + + const token = await getToken() + + expect(token).toBe('my-custom-token') + expect(mockedReadFile).not.toHaveBeenCalled() + }) + + it('reads token from file if exists', async () => { + mockedReadFile.mockResolvedValue('file-token\n') + + const token = await getToken() + + expect(token).toBe('file-token') + expect(mockedReadFile).toHaveBeenCalledWith('/tmp/hermes-test-data/.token', 'utf-8') + }) + + it('generates and saves new token if file missing', async () => { + mockedReadFile.mockRejectedValue(new Error('ENOENT')) + + const token = await getToken() + + expect(token).toBeTruthy() + expect(token).toHaveLength(64) // 32 bytes hex + expect(mockedWriteFile).toHaveBeenCalledWith( + '/tmp/hermes-test-data/.token', + expect.stringMatching(/^[a-f0-9]{64}\n$/), + { mode: 0o600 }, + ) + }) + }) + + describe('authMiddleware', () => { + function createMockCtx(path: string, headers: Record = {}, query: Record = {}) { + return { + path, + headers, + query, + status: 200, + body: null, + set: vi.fn(), + } + } + + const next = vi.fn() + + it('allows all requests when auth is disabled (null token)', async () => { + const middleware = await authMiddleware(null) + const ctx = createMockCtx('/api/hermes/sessions') + + await middleware(ctx, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('skips /health path', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/health') + + await middleware(ctx, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('skips non-API paths', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/index.html') + + await middleware(ctx, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('requires auth for /webhook path (it is an API-like endpoint)', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/webhook', {}) + + await middleware(ctx, next) + + expect(ctx.status).toBe(401) + expect(next).not.toHaveBeenCalled() + }) + + it('rejects request without auth header', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/api/hermes/sessions', {}) + + await middleware(ctx, next) + + expect(ctx.status).toBe(401) + expect(next).not.toHaveBeenCalled() + }) + + it('rejects request with wrong token', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' }) + + await middleware(ctx, next) + + expect(ctx.status).toBe(401) + expect(next).not.toHaveBeenCalled() + }) + + it('allows request with correct Bearer token', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer secret' }) + + await middleware(ctx, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('allows request with correct query token', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/api/hermes/sessions', {}, { token: 'secret' }) + + await middleware(ctx, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('returns 401 JSON on auth failure', async () => { + const middleware = await authMiddleware('secret') + const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' }) + + await middleware(ctx, next) + + expect(ctx.status).toBe(401) + expect(ctx.set).toHaveBeenCalledWith('Content-Type', 'application/json') + expect(ctx.body).toEqual({ error: 'Unauthorized' }) + }) + }) +}) diff --git a/tests/server/profiles-routes.test.ts b/tests/server/profiles-routes.test.ts new file mode 100644 index 0000000..dc64660 --- /dev/null +++ b/tests/server/profiles-routes.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock hermes-cli +vi.mock('../../packages/server/src/services/hermes-cli', () => ({ + listProfiles: vi.fn(), + getProfile: vi.fn(), + createProfile: vi.fn(), + deleteProfile: vi.fn(), + renameProfile: vi.fn(), + useProfile: vi.fn(), + stopGateway: vi.fn(), + startGateway: vi.fn(), + startGatewayBackground: vi.fn(), + setupReset: vi.fn(), + exportProfile: vi.fn(), + importProfile: vi.fn(), +})) + +import * as hermesCli from '../../packages/server/src/services/hermes-cli' + +describe('Profile Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('ensureApiServerConfig (via active profile switch)', () => { + it('should inject api_server config when missing', async () => { + // This tests the logic that profiles.ts ensures api_server config exists + // We test the ensureApiServerConfig behavior indirectly through the module + const { existsSync, readFileSync, writeFileSync } = await import('fs') + vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('platforms: {}'), + writeFileSync: vi.fn(), + createReadStream: vi.fn(), + unlinkSync: vi.fn(), + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + })) + }) + }) + + describe('hermes-cli wrapper', () => { + it('listProfiles returns array', async () => { + const mockProfiles = [{ name: 'default', active: true }] + vi.mocked(hermesCli.listProfiles).mockResolvedValue(mockProfiles as any) + + const result = await hermesCli.listProfiles() + expect(result).toEqual(mockProfiles) + }) + + it('getProfile returns profile detail', async () => { + const mockDetail = { name: 'default', path: '/tmp/default' } + vi.mocked(hermesCli.getProfile).mockResolvedValue(mockDetail as any) + + const result = await hermesCli.getProfile('default') + expect(result).toEqual(mockDetail) + expect(hermesCli.getProfile).toHaveBeenCalledWith('default') + }) + + it('createProfile calls CLI with name and clone flag', async () => { + vi.mocked(hermesCli.createProfile).mockResolvedValue('Profile created') + + await hermesCli.createProfile('test', true) + + expect(hermesCli.createProfile).toHaveBeenCalledWith('test', true) + }) + + it('deleteProfile calls CLI with name', async () => { + vi.mocked(hermesCli.deleteProfile).mockResolvedValue(true) + + await hermesCli.deleteProfile('test') + + expect(hermesCli.deleteProfile).toHaveBeenCalledWith('test') + }) + + it('renameProfile calls CLI with old and new name', async () => { + vi.mocked(hermesCli.renameProfile).mockResolvedValue(true) + + await hermesCli.renameProfile('old', 'new') + + expect(hermesCli.renameProfile).toHaveBeenCalledWith('old', 'new') + }) + }) +}) diff --git a/tests/server/proxy-handler.test.ts b/tests/server/proxy-handler.test.ts new file mode 100644 index 0000000..f055cad --- /dev/null +++ b/tests/server/proxy-handler.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock config +vi.mock('../../packages/server/src/config', () => ({ + config: { upstream: 'http://127.0.0.1:8642' }, +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +import { proxy } from '../../packages/server/src/routes/hermes/proxy-handler' + +function createMockCtx(overrides: Record = {}) { + let headersSent = false + const ctx: any = { + path: '/api/hermes/jobs', + method: 'GET', + headers: { host: 'localhost:8648', 'content-type': 'application/json' }, + query: {}, + search: '', + req: { method: 'GET' }, + res: { + write: vi.fn(), + end: vi.fn(), + headersSent: false, + writableEnded: false, + }, + request: { rawBody: undefined }, + status: 200, + set: vi.fn(), + body: null, + ...overrides, + } + return ctx +} + +describe('Proxy Handler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rewrites /api/hermes/v1/* to /v1/*', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + body: null, + json: () => Promise.resolve({ ok: true }), + }) + + const ctx = createMockCtx({ path: '/api/hermes/v1/runs', search: '' }) + await proxy(ctx) + + expect(mockFetch).toHaveBeenCalledOnce() + const url = mockFetch.mock.calls[0][0] + expect(url).toContain('/v1/runs') + expect(url).not.toContain('/api/hermes') + }) + + it('rewrites /api/hermes/* to /api/*', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + body: null, + json: () => Promise.resolve({ ok: true }), + }) + + const ctx = createMockCtx({ path: '/api/hermes/jobs', search: '' }) + await proxy(ctx) + + const url = mockFetch.mock.calls[0][0] + expect(url).toContain('/api/jobs') + expect(url).not.toContain('/api/hermes') + }) + + it('strips authorization header', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + body: null, + json: () => Promise.resolve({}), + }) + + const ctx = createMockCtx({ + headers: { host: 'localhost:8648', authorization: 'Bearer web-ui-token' }, + }) + await proxy(ctx) + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.authorization).toBeUndefined() + }) + + it('replaces host header with upstream host', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + body: null, + json: () => Promise.resolve({}), + }) + + const ctx = createMockCtx() + await proxy(ctx) + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.host).toBe('127.0.0.1:8642') + }) + + it('forwards query string', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: null, + json: () => Promise.resolve({}), + }) + + const ctx = createMockCtx({ search: '?include_disabled=true' }) + await proxy(ctx) + + const url = mockFetch.mock.calls[0][0] + expect(url).toContain('?include_disabled=true') + }) + + it('returns 502 on connection failure', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')) + + const ctx = createMockCtx() + await proxy(ctx) + + expect(ctx.status).toBe(502) + expect(ctx.body).toEqual({ error: { message: 'Proxy error: ECONNREFUSED' } }) + }) + + it('passes through non-200 status codes', async () => { + mockFetch.mockResolvedValue({ + status: 404, + headers: new Headers({ 'content-type': 'application/json' }), + body: null, + json: () => Promise.resolve({ error: 'Not found' }), + }) + + const ctx = createMockCtx() + await proxy(ctx) + + expect(ctx.status).toBe(404) + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..90ab99c --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest' + +// Client-only setup (window/localStorage only exist in jsdom) +if (typeof window !== 'undefined') { + // Mock window.matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + + // Mock localStorage + const store: Record = {} + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value }), + removeItem: vi.fn((key: string) => { delete store[key] }), + clear: vi.fn(() => { for (const k of Object.keys(store)) delete store[k] }), + get length() { return Object.keys(store).length }, + key: vi.fn((i: number) => Object.keys(store)[i] ?? null), + }, + }) +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 9f5b79f..a8dedf5 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "@vue/tsconfig/tsconfig.dom.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "types": ["vite/client"], + "types": ["vite/client", "vitest/globals"], "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..38342ea --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'path' + +export default defineConfig({ + resolve: { + alias: { + '@': resolve(__dirname, 'packages/client/src'), + }, + }, + test: { + include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], + }, +})