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.
254 lines
7.6 KiB
TypeScript
254 lines
7.6 KiB
TypeScript
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' } });
|
||
});
|
||
});
|