Fix bodyless API requests

This commit is contained in:
2026-09-05 00:01:42 +02:00
parent 6178508970
commit f878a663bd
4 changed files with 48 additions and 12 deletions

View File

@@ -4,6 +4,6 @@ export default defineConfig({
test: { test: {
root: '.', root: '.',
environment: 'node', environment: 'node',
include: ['src/**/*.test.ts'], include: ['src/**/*.test.ts', 'web/src/**/*.test.ts'],
}, },
}); });

36
web/src/api.test.ts Normal file
View File

@@ -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');
});
});

10
web/src/api.ts Normal file
View File

@@ -0,0 +1,10 @@
export async function api<T>(url: string, options?: RequestInit): Promise<T> {
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;
}

View File

@@ -1,5 +1,6 @@
import { FormEvent, useEffect, useState } from 'react'; import { FormEvent, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { api } from './api';
import './styles.css'; import './styles.css';
type Tab = 'overview' | 'hosts' | 'jobs' | 'settings'; 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 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 }> }; type RunDetail = { run: { id: number; status: string; log: string; error?: string }; artifacts: Array<{ id: number; name: string; size: number; checksum: string }> };
async function api<T>(url: string, options?: RequestInit): Promise<T> {
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 }) { function Login({ onLogin }: { onLogin: () => void }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {