Add schedule validation and service layer

This commit is contained in:
NeilClack
2026-09-11 18:16:00 -05:00
parent e6b492a335
commit 4922e941ae
4 changed files with 567 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create 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 validInput = {
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1']
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateSchedule', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
const result = await getOrCreateSchedule('u1');
expect(result).toEqual({ id: 's1', userId: 'u1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates the singleton schedule when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
await getOrCreateSchedule('u1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
});
});
describe('listBlocks', () => {
it('scopes to the user and includes proclamations with frequencies', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocks('u1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { userId: 'u1' } },
orderBy: { startMinutes: 'asc' },
include: expect.objectContaining({ proclamations: expect.anything() })
})
);
});
});
describe('createBlock', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
});
it('creates a block with deduped proclamations in order', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
const result = await createBlock('u1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
expect(result).toEqual({ ok: true, id: 'new-block' });
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
{ proclamationId: 'p2', sortOrder: 1 }
]
}
}
});
});
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(blockCreate).not.toHaveBeenCalled();
});
it('rejects an overlapping block with a specific message', async () => {
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
});
expect(blockCreate).not.toHaveBeenCalled();
});
});
describe('updateBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('u1', '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' });
blockFindMany.mockResolvedValue([]);
blockUpdate.mockResolvedValue({ id: 'b1' });
const result = await updateBlock('u1', '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(blockUpdate).toHaveBeenCalledWith({
where: { id: 'b1' },
data: {
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
}
}
});
});
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('u1', '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('u1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
});
});