Portal: per-license schedule service, shared validator, and key-only GET

Implements the shared validateSchedule validator (daily + weekly, midnight
spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a
per-License-Key schedule service (get/set type, CRUD blocks, weekly
prefill with confirmation), the content-hash version/ETag serializer, the
new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null),
and additive evolution of the legacy GET /api/v2/schedule. Adds validator,
serializer (against docs/fixtures), endpoint ETag/304, and service tests.
This commit is contained in:
NeilClack
2026-09-29 10:23:10 -05:00
parent 138bf03ea9
commit 3bb8e0189b
12 changed files with 1285 additions and 231 deletions
@@ -1,11 +1,21 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
import {
createBlock,
deleteBlock,
getScheduleForLicense,
setScheduleType,
updateBlock
} from '$lib/server/schedule';
const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function parseInput(formData: FormData) {
const startMinutes = Number(formData.get('startMinutes'));
const endMinutes = Number(formData.get('endMinutes'));
const dayRaw = formData.get('day')?.toString() ?? '';
const day = WEEK_DAYS.includes(dayRaw) ? dayRaw : null;
let proclamationIds: string[] = [];
const raw = formData.get('proclamationIds');
if (typeof raw === 'string' && raw.length > 0) {
@@ -19,16 +29,29 @@ function parseInput(formData: FormData) {
}
}
const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
return { day, startMinutes, endMinutes, proclamationIds, dynamicAudio };
}
export const load: PageServerLoad = async ({ locals }) => {
/** Ensure the given License Key belongs to the signed-in account. */
async function ownedLicense(userId: string, licenseKeyId: string) {
const license = await prisma.licenseKey.findFirst({
where: { id: licenseKeyId, userId },
select: { id: true, key: true, systemName: true }
});
return license;
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const [blocks, categories, groups, proclamations] = await Promise.all([
listBlocks(locals.user.id),
const [licenses, categories, groups, proclamations] = await Promise.all([
prisma.licenseKey.findMany({
where: { userId: locals.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, key: true, systemName: true, active: true }
}),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
@@ -45,27 +68,75 @@ export const load: PageServerLoad = async ({ locals }) => {
})
]);
const blockViews = blocks.map((block) => ({
id: block.id,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
const requested = url.searchParams.get('license');
const selected = licenses.find((l) => l.id === requested) ?? licenses[0] ?? null;
return { blocks: blockViews, categories, groups, proclamations };
let type: 'daily' | 'weekly' | null = null;
let blocks: {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
}[] = [];
if (selected) {
const schedule = await getScheduleForLicense(selected.id);
if (schedule) {
type = schedule.type === 'daily' || schedule.type === 'weekly' ? schedule.type : null;
blocks = schedule.blocks.map((block) => ({
id: block.id,
day: block.day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
}
}
return {
licenses,
selectedLicenseId: selected?.id ?? null,
type,
blocks,
categories,
groups,
proclamations
};
};
export const actions: Actions = {
setType: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const rawType = formData.get('type')?.toString() ?? '';
const type = rawType === 'daily' || rawType === 'weekly' ? rawType : null;
const confirm = formData.get('confirm') === 'true';
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
const result = await setScheduleType(licenseKeyId, type, { confirmed: confirm });
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
createBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const input = parseInput(await request.formData());
const result = await createBlock(locals.user.id, input);
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
const result = await createBlock(licenseKeyId, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
@@ -73,18 +144,27 @@ export const actions: Actions = {
updateBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const id = formData.get('id')?.toString();
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
if (!id) return fail(400, { error: 'Block not found.' });
const result = await updateBlock(locals.user.id, id, parseInput(formData));
const result = await updateBlock(licenseKeyId, id, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
deleteBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const id = (await request.formData()).get('id')?.toString();
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const id = formData.get('id')?.toString();
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
if (!id) return fail(400, { error: 'Block not found.' });
const result = await deleteBlock(locals.user.id, id);
const result = await deleteBlock(licenseKeyId, id);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
}
@@ -6,23 +6,33 @@ vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }
proclamation: { findMany: vi.fn() },
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
listBlocks: vi.fn(),
getScheduleForLicense: vi.fn(),
setScheduleType: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn()
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
import {
createBlock,
deleteBlock,
getScheduleForLicense,
setScheduleType
} from '$lib/server/schedule';
import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
@@ -37,18 +47,24 @@ beforeEach(() => {
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
listBlocksMock.mockResolvedValue([]);
licenseFindMany.mockResolvedValue([
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
]);
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
getScheduleMock.mockResolvedValue(null);
});
describe('schedule load', () => {
it('rejects unauthenticated requests', async () => {
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
await expect(
load({ locals: createLocals(), url: new URL('http://x/') } as never)
).rejects.toMatchObject({
status: 403
});
});
it('never selects proclamation content', async () => {
await load({ locals } as never);
await load({ locals, url: new URL('http://x/') } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -58,34 +74,37 @@ describe('schedule load', () => {
});
it('maps blocks to title/category/group only', async () => {
listBlocksMock.mockResolvedValue([
{
id: 'b1',
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
getScheduleMock.mockResolvedValue({
id: 's1',
type: 'daily',
blocks: [
{
id: 'b1',
day: null,
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
}
}
}
]
}
]);
]
}
]
});
const result = (await load({ locals } as never)) as {
blocks: {
id: string;
dynamicAudio: boolean;
proclamations: Record<string, unknown>[];
}[];
const result = (await load({ locals, url: new URL('http://x/') } as never)) as {
type: string | null;
blocks: { id: string; dynamicAudio: boolean; proclamations: Record<string, unknown>[] }[];
};
expect(result.type).toBe('daily');
expect(result.blocks[0].dynamicAudio).toBe(true);
expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1',
@@ -112,6 +131,7 @@ describe('schedule actions', () => {
const result = await actions.createBlock!({
request: createFormRequest({
licenseKeyId: 'lk1',
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
@@ -131,6 +151,8 @@ describe('schedule actions', () => {
const result = await actions.createBlock!({
request: createFormRequest({
licenseKeyId: 'lk1',
day: 'mon',
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']),
@@ -140,7 +162,8 @@ describe('schedule actions', () => {
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
expect(createBlockMock).toHaveBeenCalledWith('lk1', {
day: 'mon',
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2'],
@@ -148,27 +171,23 @@ describe('schedule actions', () => {
});
});
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
it('setType requiring confirmation returns the confirm error', async () => {
setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' });
await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1'])
}),
const result = await actions.setType!({
request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }),
locals
} as never);
expect(createBlockMock).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ dynamicAudio: false })
);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'confirm' });
}
});
it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({
request: createFormRequest({}),
request: createFormRequest({ licenseKeyId: 'lk1' }),
locals
} as never);
@@ -0,0 +1,48 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { buildSchedulePayload, type V2ScheduleType } from '$lib/server/v2-serializers';
function scheduleType(value: string | null | undefined): V2ScheduleType {
return value === 'daily' || value === 'weekly' ? value : null;
}
/**
* Kiosk-facing, key-only schedule read. Returns the target payload with an ETag
* (the content-derived `version`); a matching `If-None-Match` yields 304. An
* unknown License Key is 404; a key with no Schedule returns `type: null`.
*/
export const GET: RequestHandler = async ({ request, params }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `license-schedule:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const licenseKey = (params.licenseKey ?? '').trim();
const license = await prisma.licenseKey.findUnique({
where: { key: licenseKey },
select: { id: true }
});
if (!license) {
throw error(404, 'unknown_license');
}
const schedule = await getScheduleForLicense(license.id);
const type = scheduleType(schedule?.type);
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
const payload = buildSchedulePayload(type, blocks);
const etag = `"${payload.version}"`;
if (request.headers.get('if-none-match') === etag) {
return new Response(null, { status: 304, headers: { etag } });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'content-type': 'application/json', etag }
});
};
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findUnique: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
getScheduleForLicense: vi.fn(),
scheduleBlocksToInput: vi.fn()
}));
import { prisma } from '$lib/db';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { GET } from './+server';
type GETEvent = Parameters<typeof GET>[0];
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockGetSchedule = getScheduleForLicense as ReturnType<typeof vi.fn>;
const mockToInput = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
const KEY = 'ABCD-EFGH-IJKL-MNOP';
function event(ifNoneMatch?: string): GETEvent {
const headers = new Headers();
if (ifNoneMatch) headers.set('if-none-match', ifNoneMatch);
return {
request: new Request(`http://localhost/api/v2/licenses/${KEY}/schedule`, { headers }),
params: { licenseKey: KEY }
} as GETEvent;
}
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
});
describe('GET /api/v2/licenses/[licenseKey]/schedule', () => {
it('returns 404 for an unknown license', async () => {
mockFindUnique.mockResolvedValue(null);
await expect(GET(event())).rejects.toSatisfy(
(e: unknown) => isHttpError(e) && e.status === 404 && e.body.message === 'unknown_license'
);
});
it('returns type null with empty lists when there is no schedule', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue(null);
const res = await GET(event());
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.type).toBeNull();
expect(body.daily).toEqual([]);
expect(body.blocks).toEqual([]);
expect(body.version).toBeTruthy();
expect(res.headers.get('etag')).toBe(`"${body.version}"`);
});
it('returns the daily payload with an ETag', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue({ type: 'daily' });
mockToInput.mockReturnValue([
{
id: 'b1',
day: null,
startMinutes: 360,
endMinutes: 420,
dynamicAudio: true,
proclamationIds: ['p1']
}
]);
const res = await GET(event());
expect(res.status).toBe(200);
const body = (await res.json()) as { type: string; daily: unknown[]; blocks: unknown[] };
expect(body.type).toBe('daily');
expect(body.daily).toHaveLength(1);
expect(body.blocks).toHaveLength(1);
});
it('returns 304 when If-None-Match matches the ETag', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue(null);
const first = await GET(event());
const etag = first.headers.get('etag') as string;
const second = await GET(event(etag));
expect(second.status).toBe(304);
expect(second.headers.get('etag')).toBe(etag);
});
it('returns 200 when If-None-Match does not match', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue(null);
const res = await GET(event('"stale"'));
expect(res.status).toBe(200);
});
});
+15 -11
View File
@@ -3,16 +3,24 @@ import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { listBlocks } from '$lib/server/schedule';
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { buildSchedulePayload } from '$lib/server/v2-serializers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function scheduleType(value: string | null | undefined) {
return value === 'daily' || value === 'weekly' ? value : null;
}
/**
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
* `email` + `licenseKey` query parameters and validates only that the pair
* belong together — license active/entitlement is deliberately not checked
* here, since an invalid license would already lock the kiosk out.
*
* Extended additively with `version`, `type`, `daily`, and `weekly`. `email`
* and `blocks` keep their exact previous meaning (daily flattening; empty for
* weekly), so old Kiosks are unaffected. No ETag/304 here.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const ip = clientIp(request);
@@ -50,14 +58,10 @@ export const GET: RequestHandler = async ({ request, url }) => {
throw error(400, "this key doesn't appear to belong to you");
}
const blocks = await listBlocks(user.id);
const schedule = await getScheduleForLicense(storedKey.id);
const type = scheduleType(schedule?.type);
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
const payload = buildSchedulePayload(type, blocks);
const serialized = blocks.map((block) =>
serializeScheduleBlock(
block,
block.proclamations.map((entry) => entry.proclamation.id)
)
);
return jsonOk({ email, blocks: serialized });
return jsonOk({ email, ...payload });
};
+53 -28
View File
@@ -3,19 +3,24 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: { findMany: vi.fn() },
licenseKey: { findUnique: vi.fn() },
user: { findUnique: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
getScheduleForLicense: vi.fn(),
scheduleBlocksToInput: vi.fn()
}));
import { prisma } from '$lib/db';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { GET } from './+server';
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
const toInputMock = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
function call(params: Record<string, string>) {
const url = new URL('http://localhost/api/v2/schedule');
@@ -29,7 +34,8 @@ const KEY = 'AAAA-BBBB-CCCC-DDDD';
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
blockFindMany.mockResolvedValue([]);
getScheduleMock.mockResolvedValue(null);
toInputMock.mockReturnValue([]);
});
describe('GET /api/v2/schedule auth', () => {
@@ -50,7 +56,7 @@ describe('GET /api/v2/schedule auth', () => {
});
it('rejects an unknown user', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
@@ -60,7 +66,12 @@ describe('GET /api/v2/schedule auth', () => {
});
it('rejects a key that does not belong to the email account', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
licenseKeyFindUnique.mockResolvedValue({
id: 'lk1',
key: KEY,
userId: 'someone-else',
email: null
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
@@ -88,52 +99,62 @@ describe('GET /api/v2/schedule auth', () => {
describe('GET /api/v2/schedule success', () => {
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
licenseKeyFindUnique.mockResolvedValue({
id: 'lk1',
key: KEY,
userId: 'user-1',
email: null,
active: false
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
getScheduleMock.mockResolvedValue({ type: 'daily' });
toInputMock.mockReturnValue([
{
id: 'block-1',
day: null,
startMinutes: 21 * 60,
endMinutes: 9 * 60,
dynamicAudio: true,
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
proclamationIds: ['p1', 'p2']
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
email: 'jane@example.com',
blocks: [
{
id: 'block-1',
startTime: '21:00',
startMinutes: 1260,
endTime: '09:00',
endMinutes: 540,
overnight: true,
durationMinutes: 720,
dynamicAudio: true,
proclamationIds: ['p1', 'p2']
}
]
});
const body = (await response.json()) as { email: string; blocks: unknown[]; type: string };
expect(body.email).toBe('jane@example.com');
expect(body.blocks).toEqual([
{
id: 'block-1',
startTime: '21:00',
startMinutes: 1260,
endTime: '09:00',
endMinutes: 540,
overnight: true,
durationMinutes: 720,
dynamicAudio: true,
proclamationIds: ['p1', 'p2']
}
]);
// Additive fields for new Kiosks.
expect(body.type).toBe('daily');
expect(body).toHaveProperty('version');
expect(body).toHaveProperty('daily');
expect(body).toHaveProperty('weekly');
});
it('returns IDs for every selected proclamation regardless of frequency', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
getScheduleMock.mockResolvedValue({ type: 'daily' });
toInputMock.mockReturnValue([
{
id: 'block-1',
day: null,
startMinutes: 0,
endMinutes: 60,
proclamations: [{ proclamation: { id: 'p1' } }]
dynamicAudio: false,
proclamationIds: ['p1']
}
]);
@@ -157,6 +178,7 @@ describe('GET /api/v2/schedule success', () => {
it('accepts the legacy `key` query alias and an email-only license', async () => {
licenseKeyFindUnique.mockResolvedValue({
id: 'lk1',
key: KEY,
userId: null,
email: 'jane@example.com'
@@ -166,6 +188,9 @@ describe('GET /api/v2/schedule success', () => {
const response = await call({ email: 'jane@example.com', key: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
const body = (await response.json()) as { email: string; blocks: unknown[]; type: null };
expect(body.email).toBe('jane@example.com');
expect(body.blocks).toEqual([]);
expect(body.type).toBeNull();
});
});