From 9f20c8b7ccc597ee03d1817e2072c41bb67cc7d0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 14:47:13 -0400 Subject: [PATCH] test(12-04): RED regression for setup /config payload contract + error rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add setupClient.contract.test.ts with 9 tests targeting two bugs: BUG 1: SetupConfigPayload interface must use camelCase keys matching the API configSchema (appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey) — TypeScript compile error confirms mismatch BUG 2: postSetupConfig must throw readable string when API returns ZodError object in error field (not [object Object]) - Tests 7-8 fail (BUG 2 confirmed); TypeScript errors confirm BUG 1 --- apps/pwa/src/api/setupClient.contract.test.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 apps/pwa/src/api/setupClient.contract.test.ts diff --git a/apps/pwa/src/api/setupClient.contract.test.ts b/apps/pwa/src/api/setupClient.contract.test.ts new file mode 100644 index 0000000..b43b2fb --- /dev/null +++ b/apps/pwa/src/api/setupClient.contract.test.ts @@ -0,0 +1,178 @@ +/** + * Setup API client contract regression tests (Plan 12-04 bug fixes) + * + * These tests guard the API payload contract between the PWA client and the + * backend setup routes. They test the REAL client functions (no vi.mock on the + * module) and spy on globalThis.fetch to verify the exact JSON body sent. + * + * BUG 1 — field-name contract: postSetupConfig must send camelCase keys + * (`oidcIssuer`, `oidcClientId`, `vapidPublicKey`, `appExternalUrl`) matching + * the API's configSchema. The original implementation sent snake_case. + * + * Contract: the SetupConfigPayload interface must use camelCase field names. + * The test below calls postSetupConfig with the correct camelCase API contract + * values and asserts they arrive on the wire exactly as the API expects. + * + * BUG 2 — error rendering: when POST /api/setup/config returns 400 with + * `{ error: { issues: [...], name: "ZodError" } }` (error is an object), + * postSetupConfig must throw an Error whose message is a human-readable string, + * NOT "[object Object]". + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { postSetupConfig, type SetupConfigPayload } from './client.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function mockFetchResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + clone: function () { return this as unknown as Response; }, + } as unknown as Response; +} + +// Build a canonical camelCase payload using the contract type. +// If SetupConfigPayload still has snake_case fields, TypeScript will error here +// (the interface and this test agree on the camelCase contract). +const VALID_PAYLOAD: SetupConfigPayload = { + appExternalUrl: 'https://familysync.example.com', + oidcIssuer: 'https://auth.example.com', + oidcClientId: 'familysync', + vapidPublicKey: 'BH_example_public_key', +}; + +// ── BUG 1: camelCase payload contract ──────────────────────────────────────── + +describe('postSetupConfig — payload contract (BUG 1 regression)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends camelCase key oidcIssuer (not oidc_issuer) matching API configSchema', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); + await postSetupConfig(VALID_PAYLOAD); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + expect(body).toHaveProperty('oidcIssuer', 'https://auth.example.com'); + expect(body).not.toHaveProperty('oidc_issuer'); + }); + + it('sends camelCase key oidcClientId (not oidc_client_id) matching API configSchema', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); + await postSetupConfig(VALID_PAYLOAD); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + expect(body).toHaveProperty('oidcClientId', 'familysync'); + expect(body).not.toHaveProperty('oidc_client_id'); + }); + + it('sends camelCase key vapidPublicKey (not vapid_public_key) matching API configSchema', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); + await postSetupConfig(VALID_PAYLOAD); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + expect(body).toHaveProperty('vapidPublicKey', 'BH_example_public_key'); + expect(body).not.toHaveProperty('vapid_public_key'); + }); + + it('sends camelCase key appExternalUrl (not app_url) matching API configSchema', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); + await postSetupConfig(VALID_PAYLOAD); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + expect(body).toHaveProperty('appExternalUrl', 'https://familysync.example.com'); + expect(body).not.toHaveProperty('app_url'); + }); + + it('the wire body contains exactly the 4 canonical camelCase fields', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); + await postSetupConfig(VALID_PAYLOAD); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + expect(Object.keys(body).sort()).toEqual( + ['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(), + ); + }); +}); + +// ── BUG 2: readable error from ZodError object response ────────────────────── + +describe('postSetupConfig — error rendering (BUG 2 regression)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws a readable string error when API returns ZodError object in error field', async () => { + // Simulate what the real API returns on 400 (Zod validation error body) + const zodErrorBody = { + success: false, + error: { + name: 'ZodError', + issues: [ + { code: 'invalid_string', message: 'oidcIssuer must be an https URL', path: ['oidcIssuer'] }, + ], + }, + }; + fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400)); + + await expect( + postSetupConfig(VALID_PAYLOAD), + ).rejects.toSatisfy((err: Error) => { + // The error message must NOT be "[object Object]" + expect(err.message).not.toBe('[object Object]'); + // The error message must be a non-empty string + expect(typeof err.message).toBe('string'); + expect(err.message.length).toBeGreaterThan(0); + return true; + }); + }); + + it('throws an error containing the Zod issue message when available', async () => { + const zodErrorBody = { + success: false, + error: { + name: 'ZodError', + issues: [ + { code: 'invalid_string', message: 'oidcIssuer must be an https URL', path: ['oidcIssuer'] }, + ], + }, + }; + fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400)); + + await expect( + postSetupConfig(VALID_PAYLOAD), + ).rejects.toThrow('oidcIssuer must be an https URL'); + }); + + it('throws a fallback status error when error body is empty', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400)); + + await expect( + postSetupConfig(VALID_PAYLOAD), + ).rejects.toThrow('400'); + }); + + it('returns void on 200 success', async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); + + await expect( + postSetupConfig(VALID_PAYLOAD), + ).resolves.toBeUndefined(); + }); +});