/** * Unit tests for apiFetch's request shape, specifically the header merge. * * Regression guard: callers that pass a `headers` field must not lose the * default Content-Type and x-node-id that apiFetch builds. An earlier shape * spread fetchOptions over defaultOptions at the outer level after merging * headers into defaultOptions.headers, which silently clobbered the merge * when the caller supplied any `headers` value. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { apiFetch, DEPLOY_SESSION_HEADER, withDeploySession } from '../api'; const originalFetch = globalThis.fetch; beforeEach(() => { globalThis.fetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200, headers: { 'content-type': 'application/json' }, })) as unknown as typeof fetch; try { localStorage.removeItem('sencho-active-node'); } catch { /* jsdom */ } }); afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); function lastFetchInit(): RequestInit { const calls = (globalThis.fetch as ReturnType).mock.calls; return calls[calls.length - 1][1] as RequestInit; } describe('apiFetch header merge', () => { it('always sets Content-Type: application/json on the outgoing request', async () => { await apiFetch('/health'); const init = lastFetchInit(); expect((init.headers as Record)['Content-Type']).toBe('application/json'); }); it('preserves Content-Type even when the caller supplies a custom header', async () => { await apiFetch('/stacks/foo/files/content?path=app.txt', { method: 'PUT', headers: { 'If-Match': '"1700000000000"' }, body: JSON.stringify({ content: 'hi' }), }); const init = lastFetchInit(); const headers = init.headers as Record; expect(headers['Content-Type']).toBe('application/json'); expect(headers['If-Match']).toBe('"1700000000000"'); }); it('merges x-node-id when an active node is set even when caller supplies headers', async () => { localStorage.setItem('sencho-active-node', '7'); await apiFetch('/stacks/foo/files', { method: 'GET', headers: { 'X-Trace-Id': 'abc' }, }); const init = lastFetchInit(); const headers = init.headers as Record; expect(headers['x-node-id']).toBe('7'); expect(headers['Content-Type']).toBe('application/json'); expect(headers['X-Trace-Id']).toBe('abc'); localStorage.removeItem('sencho-active-node'); }); it('honours localOnly to skip x-node-id', async () => { localStorage.setItem('sencho-active-node', '7'); await apiFetch('/stacks/foo/files', { localOnly: true, headers: { 'If-Match': '"1"' } }); const init = lastFetchInit(); const headers = init.headers as Record; expect(headers['x-node-id']).toBeUndefined(); expect(headers['If-Match']).toBe('"1"'); expect(headers['Content-Type']).toBe('application/json'); localStorage.removeItem('sencho-active-node'); }); }); describe('apiFetch nodeId override', () => { it('targets the explicit node, overriding a different active node', async () => { localStorage.setItem('sencho-active-node', '7'); await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: 3 }); const init = lastFetchInit(); expect((init.headers as Record)['x-node-id']).toBe('3'); localStorage.removeItem('sencho-active-node'); }); it('targets the local node (omits x-node-id) when nodeId is null, even with an active node set', async () => { localStorage.setItem('sencho-active-node', '7'); await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: null }); const init = lastFetchInit(); expect((init.headers as Record)['x-node-id']).toBeUndefined(); localStorage.removeItem('sencho-active-node'); }); it('falls back to the active node when nodeId is undefined', async () => { localStorage.setItem('sencho-active-node', '7'); await apiFetch('/stacks/foo/deploy', { method: 'POST' }); const init = lastFetchInit(); expect((init.headers as Record)['x-node-id']).toBe('7'); localStorage.removeItem('sencho-active-node'); }); it('does not leak the nodeId option onto the outgoing fetch init', async () => { await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: 3 }); const init = lastFetchInit() as RequestInit & { nodeId?: unknown }; expect(init.nodeId).toBeUndefined(); }); it('keeps an explicit nodeId authoritative over a caller-supplied x-node-id header', async () => { await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: 3, headers: { 'x-node-id': '99' } }); const init = lastFetchInit(); expect((init.headers as Record)['x-node-id']).toBe('3'); }); it('drops a caller-supplied x-node-id when the explicit nodeId is null (local)', async () => { await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: null, headers: { 'x-node-id': '99' } }); const init = lastFetchInit(); expect((init.headers as Record)['x-node-id']).toBeUndefined(); }); }); describe('withDeploySession', () => { it('uses the canonical header name (kept in sync with the backend)', () => { expect(DEPLOY_SESSION_HEADER).toBe('x-deploy-session-id'); }); it('tags a bare POST with the session header', () => { const opts = withDeploySession('sess-123', { method: 'POST' }); expect(opts.method).toBe('POST'); expect((opts.headers as Record)[DEPLOY_SESSION_HEADER]).toBe('sess-123'); }); it('preserves caller body and headers without clobbering them', () => { const opts = withDeploySession('sess-abc', { method: 'POST', body: JSON.stringify({ a: 1 }), headers: { 'X-Custom': 'keep' }, }); const headers = opts.headers as Record; expect(opts.method).toBe('POST'); expect(opts.body).toBe(JSON.stringify({ a: 1 })); expect(headers['X-Custom']).toBe('keep'); expect(headers[DEPLOY_SESSION_HEADER]).toBe('sess-abc'); }); });