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