From 244f9ec314739e911e5e64cb3d63aa83c9b174d5 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 28 Jun 2026 13:51:01 +0200 Subject: [PATCH] 52 - Add application/json auth call path to api client --- frontend/src/lib/api.test.ts | 38 ++++++++++++++++++++++++++++++++++++ frontend/src/lib/api.ts | 28 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/api.test.ts diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..694942b --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,38 @@ +import { http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../test/server' +import { ApiError, api } from './api' + +describe('api.postJson', () => { + it('sends application/json and returns the parsed body', async () => { + let sentContentType: string | null = null + + server.use( + http.post('http://localhost/api/login', async ({ request }) => { + sentContentType = request.headers.get('Content-Type') + return HttpResponse.json({ id: 'u1', email: 'a@b.com' }) + }), + ) + + const result = await api.postJson<{ id: string; email: string }>('/login', { + username: 'a@b.com', + password: 'secret', + }) + + expect(sentContentType).toBe('application/json') + expect(result).toEqual({ id: 'u1', email: 'a@b.com' }) + }) + + it('throws ApiError on a non-2xx response', async () => { + server.use( + http.post('http://localhost/api/login', () => + HttpResponse.json({ message: 'bad creds' }, { status: 401 }), + ), + ) + + const error = await api.postJson('/login', {}).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).status).toBe(401) + }) +}) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 37592fa..d9c0611 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,7 +4,11 @@ * Auth is cookie-session (#30): every request sends credentials so the session * cookie set by POST /api/login flows on subsequent calls. In dev the Vite proxy * keeps /api same-origin, so no CORS is involved. JSON-LD (Hydra) is the wire - * format; PATCH uses application/merge-patch+json per API Platform. + * format for API Platform resources; PATCH uses application/merge-patch+json. + * + * The auth endpoints (login/register/logout) are NOT API Platform resources — + * json_login expects application/json and rejects ld+json — so they use the + * `postJson` path (plain application/json) instead of `post`. */ const BASE = '/api' @@ -23,6 +27,14 @@ export class ApiError extends Error { } async function request(path: string, init: RequestInit = {}): Promise { + // Paths are relative to BASE — callers pass `/login`, not `/api/login`. + // Guard against accidentally double-prefixing (a real footgun in tests). + if (path.startsWith(BASE)) { + throw new Error( + `API path "${path}" must not include the "${BASE}" prefix — it is added automatically.`, + ) + } + const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init, @@ -56,6 +68,20 @@ export const api = { headers: { 'Content-Type': 'application/ld+json' }, }), + /** + * POST with plain application/json — for the auth endpoints (login, register, + * logout) which are not API Platform resources and reject ld+json. + */ + postJson: (path: string, body: unknown): Promise => + request(path, { + method: 'POST', + body: JSON.stringify(body), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }), + patch: (path: string, body: unknown): Promise => request(path, { method: 'PATCH',