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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user