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
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PrismaClient } from '$lib/generated/prisma/client';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import {
getScheduleForLicense,
setScheduleType,
createBlock,
updateBlock,
copySchedule,
listBlocksForLicense,
scheduleBlocksToInput,
type OwnedLicense
} from '$lib/server/schedule';
import { buildSchedulePayload } from '$lib/server/v2-serializers';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const K_A1 = 'ALCE-AAAA-AAAA-0001';
const K_A2 = 'ALCE-AAAA-AAAA-0002';
const K_B1 = 'BOBB-BBBB-BBBB-0001';
const results: { id: string; pass: boolean; note: string }[] = [];
let PROCS: string[] = [];
async function licenseByKey(key: string) {
return prisma.licenseKey.findUnique({ where: { key } });
}
async function ownedFor(email: string): Promise<OwnedLicense[]> {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new Error('no user');
return prisma.licenseKey.findMany({
where: { userId: user.id },
select: { id: true, systemName: true, key: true }
});
}
function payloadFor(schedule: Awaited<ReturnType<typeof getScheduleForLicense>>) {
const type = schedule?.type === 'daily' || schedule?.type === 'weekly' ? schedule.type : null;
return buildSchedulePayload(type, scheduleBlocksToInput(schedule?.blocks ?? []));
}
beforeAll(async () => {
const [ka1, ka2, kb1] = await Promise.all([
licenseByKey(K_A1),
licenseByKey(K_A2),
licenseByKey(K_B1)
]);
await prisma.schedule.deleteMany({
where: { licenseKeyId: { in: [ka1!.id, ka2!.id, kb1!.id] } }
});
const procs = await prisma.proclamation.findMany({
where: { name: { startsWith: 'P5 ' } },
select: { id: true },
orderBy: { name: 'asc' }
});
PROCS = procs.map((p) => p.id);
expect(PROCS.length).toBeGreaterThanOrEqual(4);
});
afterAll(async () => {
console.log('\nP5_RESULTS ' + JSON.stringify(results));
await prisma.$disconnect();
await pool.end();
});
const enabled = process.env.P5_INTEGRATION === '1';
describe.skipIf(!enabled)('Phase 5 end-to-end service scenarios', () => {
it('e: create a daily schedule via the Portal path', async () => {
const ka1 = await licenseByKey(K_A1);
await setScheduleType(ka1!.id, 'daily', { confirmed: true });
await createBlock(ka1!.id, {
startMinutes: 7 * 60,
endMinutes: 9 * 60,
proclamationIds: [PROCS[0]],
dynamicAudio: true
});
await createBlock(ka1!.id, {
startMinutes: 18 * 60,
endMinutes: 20 * 60 + 30,
proclamationIds: [PROCS[1]],
dynamicAudio: false
});
const daily = await getScheduleForLicense(ka1!.id);
const p = payloadFor(daily);
results.push({
id: 'e',
pass: p.type === 'daily' && p.daily.length === 2,
note: `type=${p.type} blocks=${p.daily.length}`
});
expect(p.type).toBe('daily');
expect(p.daily).toHaveLength(2);
});
it('f: weekly adjacent + midnight-crossing + Sunday spillover rejection', async () => {
const kb1 = await licenseByKey(K_B1);
await setScheduleType(kb1!.id, 'weekly', { confirmed: true });
await createBlock(kb1!.id, {
day: 'sun',
startMinutes: 23 * 60,
endMinutes: 60,
proclamationIds: [PROCS[2]]
});
await createBlock(kb1!.id, {
day: 'mon',
startMinutes: 6 * 60,
endMinutes: 7 * 60,
proclamationIds: [PROCS[0]]
});
await createBlock(kb1!.id, {
day: 'mon',
startMinutes: 7 * 60,
endMinutes: 8 * 60 + 30,
proclamationIds: [PROCS[1]]
});
const p = payloadFor(await getScheduleForLicense(kb1!.id));
expect(p.type).toBe('weekly');
expect(p.weekly.sun).toHaveLength(1);
expect(p.weekly.sun[0].overnight).toBe(true);
expect(p.weekly.mon).toHaveLength(2);
expect(p.weekly.mon[0].endMinutes).toBe(p.weekly.mon[1].startMinutes);
// Sunday's 23:00->01:00 spills into Monday [00:00,01:00); a Monday 00:00-00:30 block must be rejected.
const rejected = await createBlock(kb1!.id, {
day: 'mon',
startMinutes: 0,
endMinutes: 30,
proclamationIds: [PROCS[3]]
});
const monCount = (await getScheduleForLicense(kb1!.id))!.blocks.filter(
(b) => b.day === 'mon'
).length;
results.push({
id: 'f',
pass: !rejected.ok && monCount === 2,
note: `sun overnight=${p.weekly.sun[0].overnight} mon adjacent=${p.weekly.mon[0].endMinutes === p.weekly.mon[1].startMinutes} spilloverRejected=${!rejected.ok}`
});
expect(rejected.ok).toBe(false);
expect(monCount).toBe(2);
});
it('i: schedules are per-License-Key (A does not affect B)', async () => {
const [ka1, kb1] = await Promise.all([licenseByKey(K_A1), licenseByKey(K_B1)]);
const a = payloadFor(await getScheduleForLicense(ka1!.id));
const b = payloadFor(await getScheduleForLicense(kb1!.id));
results.push({
id: 'i',
pass: a.type === 'daily' && b.type === 'weekly',
note: `A=${a.type} B=${b.type}`
});
expect(a.type).toBe('daily');
expect(b.type).toBe('weekly');
});
it('j: copy A -> second system and verify independence', async () => {
const [ka1, ka2] = await Promise.all([licenseByKey(K_A1), licenseByKey(K_A2)]);
const owned = await ownedFor('p5-alice@example.com');
const copy = await copySchedule(owned, ka1!.id, [ka2!.id]);
expect(copy.ok).toBe(true);
const ka2Before = payloadFor(await getScheduleForLicense(ka2!.id));
expect(ka2Before.type).toBe('daily');
expect(ka2Before.daily).toHaveLength(2);
const src = payloadFor(await getScheduleForLicense(ka1!.id));
expect(src.version).not.toBe(ka2Before.version);
// Edit source; copy must not change.
const blocks = await listBlocksForLicense(ka1!.id);
await updateBlock(ka1!.id, blocks[0].id, {
startMinutes: 6 * 60,
endMinutes: 7 * 60,
proclamationIds: blocks[0].proclamations.map((p) => p.proclamation.id),
dynamicAudio: true
});
const ka2After = payloadFor(await getScheduleForLicense(ka2!.id));
results.push({
id: 'j',
pass: ka2After.version === ka2Before.version && src.version !== ka2Before.version,
note: `copied daily blocks=${ka2Before.daily.length} independent=${ka2After.version === ka2Before.version}`
});
expect(ka2After.version).toBe(ka2Before.version);
});
});