Fix bodyless API requests
This commit is contained in:
@@ -4,6 +4,6 @@ export default defineConfig({
|
||||
test: {
|
||||
root: '.',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'web/src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
36
web/src/api.test.ts
Normal file
36
web/src/api.test.ts
Normal 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
10
web/src/api.ts
Normal 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;
|
||||
}
|
||||
@@ -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<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 }) {
|
||||
const [error, setError] = useState('');
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
|
||||
Reference in New Issue
Block a user