diff --git a/vitest.config.ts b/vitest.config.ts index 57d46a6..1f89716 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { root: '.', environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'web/src/**/*.test.ts'], }, }); diff --git a/web/src/api.test.ts b/web/src/api.test.ts new file mode 100644 index 0000000..d5c5c5a --- /dev/null +++ b/web/src/api.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { api } from './api'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('API requests', () => { + it('does not send a content type for a bodyless request', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + await api('/api/hosts/1/test', { method: 'POST' }); + + const options = fetchMock.mock.calls[0]![1] as RequestInit; + expect((options.headers as Headers).has('content-type')).toBe(false); + }); + + it('sends JSON content type when a body is present', async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ saved: true })); + vi.stubGlobal('fetch', fetchMock); + + await api('/api/hosts', { method: 'POST', body: JSON.stringify({ name: 'server' }) }); + + const options = fetchMock.mock.calls[0]![1] as RequestInit; + expect((options.headers as Headers).get('content-type')).toBe('application/json'); + }); + + it('preserves an explicitly supplied content type', async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ saved: true })); + vi.stubGlobal('fetch', fetchMock); + + await api('/api/import', { method: 'POST', headers: { 'content-type': 'text/plain' }, body: 'value' }); + + const options = fetchMock.mock.calls[0]![1] as RequestInit; + expect((options.headers as Headers).get('content-type')).toBe('text/plain'); + }); +}); diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..31e1495 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,10 @@ +export async function api(url: string, options?: RequestInit): Promise { + const headers = new Headers(options?.headers); + if (options?.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json'); + + const response = await fetch(url, { ...options, headers }); + if (response.status === 204) return undefined as T; + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? 'Request failed'); + return body as T; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index d3204cf..9714b8e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,5 +1,6 @@ import { FormEvent, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import { api } from './api'; import './styles.css'; type Tab = 'overview' | 'hosts' | 'jobs' | 'settings'; @@ -15,17 +16,6 @@ type Job = { id: number; name: string; hostName: string; config: { steps: Array< type Run = { id: number; jobName: string; status: string; trigger: string; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; artifactCount: number }; type RunDetail = { run: { id: number; status: string; log: string; error?: string }; artifacts: Array<{ id: number; name: string; size: number; checksum: string }> }; -async function api(url: string, options?: RequestInit): Promise { - const response = await fetch(url, { - ...options, - headers: { 'content-type': 'application/json', ...options?.headers }, - }); - if (response.status === 204) return undefined as T; - const body = await response.json(); - if (!response.ok) throw new Error(body.error ?? 'Request failed'); - return body as T; -} - function Login({ onLogin }: { onLogin: () => void }) { const [error, setError] = useState(''); async function submit(event: FormEvent) {