Add schedule validation and service layer
This commit is contained in:
@@ -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' } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user