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:
NeilClack
2026-09-29 12:46:04 -05:00
parent be303dcd9c
commit e6021a6345
5 changed files with 466 additions and 0 deletions
+163
View File
@@ -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);
});
+45
View File
@@ -0,0 +1,45 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
// A handful of real Proclamations so schedule Blocks have valid references.
async function main() {
const category = await prisma.proclamationCategory.findFirst({ orderBy: { id: 'asc' } });
const group = await prisma.proclamationGroup.findFirst({ orderBy: { id: 'asc' } });
const freq = await prisma.solfeggioFrequency.findFirst({ orderBy: { id: 'asc' } });
if (!category || !group || !freq) throw new Error('seed missing catalog');
const names = ['P5 Dawn', 'P5 Dusk', 'P5 Noon', 'P5 Night'];
const ids: string[] = [];
for (const name of names) {
const existing = await prisma.proclamation.findUnique({ where: { name } });
if (existing) {
ids.push(existing.id);
continue;
}
const created = await prisma.proclamation.create({
data: {
name,
content: `<p>${name} content</p>`,
categoryId: category.id,
groupId: group.id,
solfeggioFrequencyId: freq.id
}
});
ids.push(created.id);
}
console.log(JSON.stringify(ids));
await prisma.$disconnect();
await pool.end();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
// Two accounts, each with two License Keys, for scenario coverage:
// - acct A: keys KA1, KA2 (same account -> name uniqueness + copy)
// - acct B: key KB1 (cross-account isolation)
// Names are left NULL; the Kiosk sets them via the API.
const accounts = [
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0001' },
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0002' },
{ email: 'p5-bob@example.com', key: 'BOBB-BBBB-BBBB-0001' }
];
async function ensureUser(email) {
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({
data: { id: crypto.randomUUID(), name: email.split('@')[0], email, emailVerified: true }
});
}
return user;
}
async function main() {
for (const { email, key } of accounts) {
const user = await ensureUser(email);
const existing = await prisma.licenseKey.findUnique({ where: { key } });
if (!existing) {
await prisma.licenseKey.create({ data: { key, active: true, userId: user.id } });
}
}
console.log('P5 test data ready:');
for (const { email, key } of accounts) console.log(` ${email} -> ${key}`);
await prisma.$disconnect();
await pool.end();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
const schedules = await prisma.schedule.findMany({ include: { blocks: true } });
const keys = await prisma.licenseKey.findMany({ select: { key: true, systemName: true } });
console.log(
JSON.stringify({
scheduleCount: schedules.length,
blockCount: schedules.reduce((n, s) => n + s.blocks.length, 0),
types: schedules.map((s) => s.type),
names: keys
.filter((k) => k.systemName)
.map((k) => k.systemName)
.sort()
})
);
await prisma.$disconnect();
await pool.end();