Fix bodyless API requests
This commit is contained in:
@@ -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
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 { 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>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user