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
@@ -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);