test(12-04): RED regression for setup /config payload contract + error rendering
- 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
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user