test: Phase 5 verification scripts and opt-in e2e integration spec
Adds scripts to seed scenario data, create Proclamations, snapshot schedules, drive the Kiosk-facing HTTP endpoints, and an opt-in (P5_INTEGRATION=1) Vitest spec exercising create/type/copy against a scratch database. The integration spec is skipped by default so the normal suite needs no DB.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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<string, string> = {}) {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user