From e6021a634567c332124895187b23023111d648f6 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 12:46:04 -0500 Subject: [PATCH] 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. --- scripts/p5-http-scenarios.ts | 163 +++++++++++++++++++ scripts/p5-proclamations.ts | 45 ++++++ scripts/p5-seed.ts | 49 ++++++ scripts/p5-snapshot.ts | 21 +++ src/lib/server/p5-e2e.integration.spec.ts | 188 ++++++++++++++++++++++ 5 files changed, 466 insertions(+) create mode 100644 scripts/p5-http-scenarios.ts create mode 100644 scripts/p5-proclamations.ts create mode 100644 scripts/p5-seed.ts create mode 100644 scripts/p5-snapshot.ts create mode 100644 src/lib/server/p5-e2e.integration.spec.ts diff --git a/scripts/p5-http-scenarios.ts b/scripts/p5-http-scenarios.ts new file mode 100644 index 0000000..e4366fc --- /dev/null +++ b/scripts/p5-http-scenarios.ts @@ -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 = {}) { + 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); +}); diff --git a/scripts/p5-proclamations.ts b/scripts/p5-proclamations.ts new file mode 100644 index 0000000..fe50bd5 --- /dev/null +++ b/scripts/p5-proclamations.ts @@ -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: `

${name} content

`, + 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); +}); diff --git a/scripts/p5-seed.ts b/scripts/p5-seed.ts new file mode 100644 index 0000000..dfb0112 --- /dev/null +++ b/scripts/p5-seed.ts @@ -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); +}); diff --git a/scripts/p5-snapshot.ts b/scripts/p5-snapshot.ts new file mode 100644 index 0000000..86b11fd --- /dev/null +++ b/scripts/p5-snapshot.ts @@ -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(); diff --git a/src/lib/server/p5-e2e.integration.spec.ts b/src/lib/server/p5-e2e.integration.spec.ts new file mode 100644 index 0000000..f7d2203 --- /dev/null +++ b/src/lib/server/p5-e2e.integration.spec.ts @@ -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 { + 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>) { + 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); + }); +});