Files
portal/src/lib/server/schedule.spec.ts
T
NeilClack 3bb8e0189b 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.
2026-09-29 10:23:10 -05:00

254 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
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(),
deleteMany: vi.fn()
},
blockProclamations: { createMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
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,
endMinutes: 8 * 60,
proclamationIds: ['p1']
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateScheduleForLicense', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
const result = await getOrCreateScheduleForLicense('lk1');
expect(result).toEqual({ id: 's1', licenseKeyId: 'lk1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates a schedule keyed by license when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
await getOrCreateScheduleForLicense('lk1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { licenseKeyId: 'lk1' } });
});
});
describe('listBlocksForLicense', () => {
it('scopes to the license key', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocksForLicense('lk1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
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', 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('lk1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
expect(result).toEqual({ ok: true, id: 'new-block' });
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
day: null,
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: true,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
{ proclamationId: 'p2', sortOrder: 1 }
]
}
}
});
});
it('rejects when no type is set', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
const result = await createBlock('lk1', validInput);
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', day: null, startMinutes: 7 * 60, endMinutes: 9 * 60, proclamations: [] }
]);
const result = await createBlock('lk1', validInput);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
});
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 license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('lk1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
});
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('lk1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
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,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
}
}
});
});
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('lk1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
});
it('deletes an owned block', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('lk1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
});
});