Phase 12: Initial Setup Wizard #22

Merged
luckberg merged 76 commits from gsd/phase-12-initial-setup-wizard into main 2026-06-16 19:10:33 -04:00
Showing only changes of commit 9f20c8b7cc - Show all commits
@@ -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();
});
});