Phase 12: Initial Setup Wizard #22
@@ -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