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
+106 -79
View File
@@ -2,27 +2,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
schedule: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
licenseKey: { findUnique: vi.fn() },
block: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
createMany: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
delete: vi.fn(),
deleteMany: vi.fn()
},
blockProclamations: { createMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
import {
createBlock,
deleteBlock,
getOrCreateScheduleForLicense,
listBlocksForLicense,
setScheduleType,
updateBlock
} from './schedule';
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
const scheduleUpdate = prisma.schedule.update as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
const blockDeleteMany = prisma.block.deleteMany as ReturnType<typeof vi.fn>;
const validInput = {
startMinutes: 6 * 60,
@@ -34,52 +47,95 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateSchedule', () => {
describe('getOrCreateScheduleForLicense', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
const result = await getOrCreateSchedule('u1');
const result = await getOrCreateScheduleForLicense('lk1');
expect(result).toEqual({ id: 's1', userId: 'u1' });
expect(result).toEqual({ id: 's1', licenseKeyId: 'lk1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates the singleton schedule when missing', async () => {
it('creates a schedule keyed by license when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
scheduleCreate.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
await getOrCreateSchedule('u1');
await getOrCreateScheduleForLicense('lk1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
expect(scheduleCreate).toHaveBeenCalledWith({ data: { licenseKeyId: 'lk1' } });
});
});
describe('listBlocks', () => {
it('scopes to the user and includes proclamations with frequencies', async () => {
describe('listBlocksForLicense', () => {
it('scopes to the license key', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocks('u1');
await listBlocksForLicense('lk1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { userId: 'u1' } },
orderBy: { startMinutes: 'asc' },
where: { schedule: { licenseKeyId: 'lk1' } },
include: expect.objectContaining({ proclamations: expect.anything() })
})
);
});
});
describe('setScheduleType', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
});
it('set type when empty', async () => {
blockFindMany.mockResolvedValue([]);
scheduleUpdate.mockResolvedValue({});
const result = await setScheduleType('lk1', 'daily');
expect(result).toEqual({ ok: true });
expect(scheduleUpdate).toHaveBeenCalledWith({
where: { id: 's1' },
data: { type: 'daily' }
});
});
it('requires confirmation to switch a populated schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
blockFindMany.mockResolvedValue([
{ id: 'b1', startMinutes: 0, endMinutes: 60, dynamicAudio: true, proclamations: [] }
]);
const result = await setScheduleType('lk1', 'weekly');
expect(result).toEqual({ ok: false, error: 'confirm' });
expect(scheduleUpdate).not.toHaveBeenCalled();
});
it('clearing to null deletes the blocks', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
blockFindMany.mockResolvedValue([]);
blockDeleteMany.mockResolvedValue({ count: 0 });
scheduleUpdate.mockResolvedValue({});
const result = await setScheduleType('lk1', null, { confirmed: true });
expect(result).toEqual({ ok: true });
expect(blockDeleteMany).toHaveBeenCalledWith({ where: { scheduleId: 's1' } });
expect(scheduleUpdate).toHaveBeenCalledWith({ where: { id: 's1' }, data: { type: null } });
});
});
describe('createBlock', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
});
it('creates a block with deduped proclamations in order', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
const result = await createBlock('u1', {
const result = await createBlock('lk1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
@@ -88,6 +144,7 @@ describe('createBlock', () => {
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
day: null,
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: true,
@@ -101,63 +158,21 @@ describe('createBlock', () => {
});
});
it('defaults dynamic audio on when the field is absent', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
it('rejects when no type is set', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
await createBlock('u1', validInput);
const result = await createBlock('lk1', validInput);
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: true })
})
);
});
it('persists dynamic audio off when the field is explicitly false', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
await createBlock('u1', { ...validInput, dynamicAudio: false });
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: false })
})
);
});
it('persists the dynamic audio flag', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
await createBlock('u1', { ...validInput, dynamicAudio: true });
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: true })
})
);
});
it('rejects a fifth block without creating', async () => {
blockFindMany.mockResolvedValue([
{ id: 'a', startMinutes: 0, endMinutes: 60 },
{ id: 'b', startMinutes: 60, endMinutes: 120 },
{ id: 'c', startMinutes: 120, endMinutes: 180 },
{ id: 'd', startMinutes: 180, endMinutes: 240 }
]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
expect(result).toEqual({ ok: false, error: 'Set a schedule type first.' });
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an overlapping block with a specific message', async () => {
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
blockFindMany.mockResolvedValue([
{ id: 'a', day: null, startMinutes: 7 * 60, endMinutes: 9 * 60, proclamations: [] }
]);
const result = await createBlock('u1', validInput);
const result = await createBlock('lk1', validInput);
expect(result).toEqual({
ok: false,
@@ -165,13 +180,22 @@ describe('createBlock', () => {
});
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an empty block before creating', async () => {
blockFindMany.mockResolvedValue([]);
const result = await createBlock('lk1', { ...validInput, proclamationIds: [] });
expect(result).toEqual({ ok: false, error: 'Select at least one proclamation.' });
expect(blockCreate).not.toHaveBeenCalled();
});
});
describe('updateBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('u1', 'missing', validInput);
const result = await updateBlock('lk1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
@@ -179,19 +203,22 @@ describe('updateBlock', () => {
it('replaces proclamations and excludes itself from overlap checks', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
blockFindMany.mockResolvedValue([]);
blockUpdate.mockResolvedValue({ id: 'b1' });
const result = await updateBlock('u1', 'b1', validInput);
const result = await updateBlock('lk1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockFindMany).toHaveBeenCalledWith({
where: { scheduleId: 's1', id: { not: 'b1' } },
select: { id: true, startMinutes: true, endMinutes: true }
});
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { scheduleId: 's1', id: { not: 'b1' } }
})
);
expect(blockUpdate).toHaveBeenCalledWith({
where: { id: 'b1' },
data: {
day: null,
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: true,
@@ -205,10 +232,10 @@ describe('updateBlock', () => {
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('u1', 'missing');
const result = await deleteBlock('lk1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
@@ -218,7 +245,7 @@ describe('deleteBlock', () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('u1', 'b1');
const result = await deleteBlock('lk1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });