import 'dotenv/config'; // Exercises the Kiosk-facing HTTP API exactly as the Kiosk client does. // Scenarios: b (validate + name), c (duplicate name conflict), k (ETag/304), // old-client backward compatibility. const BASE = process.env.P5_BASE ?? 'http://localhost:5173'; const KA1 = 'ALCE-AAAA-AAAA-0001'; const KA2 = 'ALCE-AAAA-AAAA-0002'; const KB1 = 'BOBB-BBBB-BBBB-0001'; const EMAIL = 'p5-alice@example.com'; const results: { id: string; pass: boolean; note: string }[] = []; function rec(id: string, pass: boolean, note: string) { results.push({ id, pass, note }); console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${note}`); } async function post(path: string, body: unknown) { const res = await fetch(BASE + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: res.status, body: await res.json().catch(() => null), headers: res.headers }; } async function put(path: string, body: unknown) { const res = await fetch(BASE + path, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: res.status, body: await res.json().catch(() => null) }; } async function get(path: string, headers: Record = {}) { const res = await fetch(BASE + path, { headers }); return { status: res.status, body: await res.json().catch(() => null), headers: res.headers }; } async function main() { // (b) validate without a name first (old-client behavior), then set a name. const validateNoName = await post('/api/v2/license', { license_key: KA1, email_address: EMAIL }); rec( 'b1', validateNoName.status === 200 && validateNoName.body?.systemNameStatus === 'unchanged', `validation without systemName -> ${validateNoName.status}, status=${validateNoName.body?.systemNameStatus}` ); const setName = await post('/api/v2/license', { license_key: KA1, email_address: EMAIL, systemName: 'Living Room' }); rec( 'b2', setName.status === 200 && setName.body?.systemNameStatus === 'ok', `name a Kiosk -> ${setName.status}, status=${setName.body?.systemNameStatus}` ); // (c) duplicate name, different casing, same account -> conflict + not cleared. const dup = await post('/api/v2/license', { license_key: KA2, email_address: EMAIL, systemName: 'living room' }); rec( 'c1', dup.status === 200 && dup.body?.systemNameStatus === 'conflict', `case-insensitive duplicate -> status=${dup.body?.systemNameStatus} (validation still ${dup.status})` ); // Confirm the original name survived and KA2 has no name. const after = await get('/api/v2/licenses/' + KA1 + '/schedule'); rec('c2', after.status === 200, `KA1 schedule GET after conflict -> ${after.status}`); // (c) PUT system-name endpoint: 409 for a taken name. const putDup = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: 'LIVING ROOM' }); rec( 'c3', putDup.status === 409 && putDup.body?.message === 'name_taken', `PUT duplicate -> ${putDup.status} ${putDup.body?.message}` ); const putOk = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: 'Kitchen' }); rec('c4', putOk.status === 200, `PUT unique name -> ${putOk.status}`); const putInvalid = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: ' ' }); rec( 'c5', putInvalid.status === 400 && putInvalid.body?.message === 'invalid_name', `PUT blank -> ${putInvalid.status} ${putInvalid.body?.message}` ); const putUnknown = await put('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/system-name', { systemName: 'X' }); rec( 'c6', putUnknown.status === 404 && putUnknown.body?.message === 'unknown_license', `PUT unknown license -> ${putUnknown.status} ${putUnknown.body?.message}` ); // (k) schedule GET parity between the legacy endpoint and the new key-only endpoint. const modern = await get('/api/v2/licenses/' + KA1 + '/schedule'); const legacy = await get( '/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KA1 ); rec( 'k1', modern.status === 200 && legacy.status === 200 && modern.body?.version === legacy.body?.version, `modern vs legacy version equal=${modern.body?.version === legacy.body?.version}` ); // (k) conditional GET: matching If-None-Match -> 304. const etag = modern.headers.get('etag') ?? ''; const notModified = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': etag }); rec('k2', notModified.status === 304, `conditional GET with ETag -> ${notModified.status}`); const stale = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': '"stale"' }); rec('k3', stale.status === 200, `conditional GET with stale ETag -> ${stale.status}`); // Unknown license -> 404. const missing = await get('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/schedule'); rec('k4', missing.status === 404, `unknown license schedule GET -> ${missing.status}`); // Old-client validation (no systemName field at all) never clears the name. const validateAgain = await post('/api/v2/license', { license_key: KA1, email_address: EMAIL }); const checkName = await put('/api/v2/licenses/' + KA1 + '/system-name', { systemName: 'Living Room' }); rec( 'old1', validateAgain.status === 200 && validateAgain.body?.systemNameStatus === 'unchanged' && checkName.status === 200, `old-client validate stays unchanged; name still present` ); // Old-client list endpoint shape: blocks array still present. rec( 'old2', legacy.status === 200 && Array.isArray(legacy.body?.blocks), `legacy endpoint still returns email+blocks (${legacy.body?.blocks?.length} blocks)` ); // Cross-account: Bob's key with Alice's email must fail. const cross = await get( '/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KB1 ); rec('auth1', cross.status === 400, `cross-account legacy schedule -> ${cross.status}`); console.log('\nP5_HTTP_RESULTS ' + JSON.stringify(results)); } main().catch((e) => { console.error('RUNNER ERROR', e); process.exit(1); });