import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { handle } from './hooks.server'; // `BACKEND_URL` is read at module load time, so the values used in the // asserts below assume the test env didn't set it. `?? 'http://localhost:8080'` // is the default. const DEFAULT_BACKEND = 'http://localhost:8080'; function makeEvent(path: string, init?: RequestInit) { const url = new URL(`http://app.example.com${path}`); const request = new Request(url, init); return { url, request } as Parameters[0]['event']; } describe('hooks.server proxy', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('forwards /api/* requests to the backend, preserving status', async () => { fetchSpy.mockResolvedValueOnce( new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'content-type': 'application/json' } }) ); const resolve = vi.fn(); const event = makeEvent('/api/v1/health'); const resp = await handle({ event, resolve }); expect(resolve).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy.mock.calls[0][0]).toBe(`${DEFAULT_BACKEND}/api/v1/health`); expect(resp.status).toBe(200); expect(await resp.json()).toEqual({ status: 'ok' }); }); it('passes through the query string', async () => { fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })); const resolve = vi.fn(); await handle({ event: makeEvent('/api/v1/mangas?search=narto&limit=10'), resolve }); expect(fetchSpy.mock.calls[0][0]).toBe( `${DEFAULT_BACKEND}/api/v1/mangas?search=narto&limit=10` ); }); it('strips the host header so the backend sees its own origin', async () => { fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })); const resolve = vi.fn(); await handle({ event: makeEvent('/api/v1/health', { headers: { host: 'app.example.com', cookie: 'mangalord_session=abc' } }), resolve }); const init = fetchSpy.mock.calls[0][1] as RequestInit; const headers = init.headers as Headers; expect(headers.get('host')).toBeNull(); // Cookies must still be forwarded — that's how the session reaches axum. expect(headers.get('cookie')).toBe('mangalord_session=abc'); }); it('forwards request bodies on POST', async () => { fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 201 })); const resolve = vi.fn(); await handle({ event: makeEvent('/api/v1/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'alice', password: 'hunter2hunter2' }) }), resolve }); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(init.body).toBeDefined(); }); it('delegates non-/api requests to SvelteKit', async () => { const resolve = vi.fn().mockResolvedValue(new Response('page', { status: 200 })); await handle({ event: makeEvent('/manga/abc'), resolve }); expect(fetchSpy).not.toHaveBeenCalled(); expect(resolve).toHaveBeenCalledTimes(1); }); it('returns 502 with the standard error envelope when the upstream is unreachable', async () => { // Silence the console.error the handler emits on failure so // the test output stays clean. const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed')); const resolve = vi.fn(); const resp = await handle({ event: makeEvent('/api/v1/health'), resolve }); expect(resolve).not.toHaveBeenCalled(); expect(resp.status).toBe(502); expect(resp.headers.get('content-type')).toContain('application/json'); const body = await resp.json(); expect(body.error.code).toBe('upstream_unavailable'); expect(errSpy).toHaveBeenCalled(); }); });