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.
201 lines
5.6 KiB
TypeScript
201 lines
5.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { isActionFailure } from '@sveltejs/kit';
|
|
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
|
|
|
|
vi.mock('$lib/db', () => ({
|
|
prisma: {
|
|
proclamationCategory: { findMany: vi.fn() },
|
|
proclamationGroup: { findMany: vi.fn() },
|
|
proclamation: { findMany: vi.fn() },
|
|
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() }
|
|
}
|
|
}));
|
|
|
|
vi.mock('$lib/server/schedule', () => ({
|
|
getScheduleForLicense: vi.fn(),
|
|
setScheduleType: vi.fn(),
|
|
createBlock: vi.fn(),
|
|
updateBlock: vi.fn(),
|
|
deleteBlock: vi.fn()
|
|
}));
|
|
|
|
import { prisma } from '$lib/db';
|
|
import {
|
|
createBlock,
|
|
deleteBlock,
|
|
getScheduleForLicense,
|
|
setScheduleType
|
|
} from '$lib/server/schedule';
|
|
import { actions, load } from './+page.server';
|
|
|
|
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
|
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
|
|
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
|
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
|
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
|
|
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
|
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
|
|
|
|
const locals = createLocals(mockUser() as never);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{ id: 2, name: 'Physical' }
|
|
]);
|
|
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{ id: 1, name: 'General' }
|
|
]);
|
|
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
|
|
licenseFindMany.mockResolvedValue([
|
|
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
|
|
]);
|
|
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
|
|
getScheduleMock.mockResolvedValue(null);
|
|
});
|
|
|
|
describe('schedule load', () => {
|
|
it('rejects unauthenticated requests', async () => {
|
|
await expect(
|
|
load({ locals: createLocals(), url: new URL('http://x/') } as never)
|
|
).rejects.toMatchObject({
|
|
status: 403
|
|
});
|
|
});
|
|
|
|
it('never selects proclamation content', async () => {
|
|
await load({ locals, url: new URL('http://x/') } as never);
|
|
|
|
expect(procFindMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
select: { id: true, name: true, categoryId: true, groupId: true }
|
|
})
|
|
);
|
|
});
|
|
|
|
it('maps blocks to title/category/group only', async () => {
|
|
getScheduleMock.mockResolvedValue({
|
|
id: 's1',
|
|
type: 'daily',
|
|
blocks: [
|
|
{
|
|
id: 'b1',
|
|
day: null,
|
|
startMinutes: 360,
|
|
endMinutes: 480,
|
|
dynamicAudio: true,
|
|
proclamations: [
|
|
{
|
|
proclamation: {
|
|
id: 'p1',
|
|
name: 'Peace',
|
|
categoryId: 2,
|
|
groupId: 1,
|
|
content: '<p>secret</p>'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
const result = (await load({ locals, url: new URL('http://x/') } as never)) as {
|
|
type: string | null;
|
|
blocks: { id: string; dynamicAudio: boolean; proclamations: Record<string, unknown>[] }[];
|
|
};
|
|
|
|
expect(result.type).toBe('daily');
|
|
expect(result.blocks[0].dynamicAudio).toBe(true);
|
|
expect(result.blocks[0].proclamations[0]).toEqual({
|
|
id: 'p1',
|
|
name: 'Peace',
|
|
categoryId: 2,
|
|
groupId: 1
|
|
});
|
|
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
|
|
});
|
|
});
|
|
|
|
describe('schedule actions', () => {
|
|
it('createBlock rejects unauthenticated requests', async () => {
|
|
await expect(
|
|
actions.createBlock!({
|
|
request: createFormRequest({}),
|
|
locals: createLocals()
|
|
} as never)
|
|
).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it('createBlock surfaces the service error', async () => {
|
|
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
|
|
|
|
const result = await actions.createBlock!({
|
|
request: createFormRequest({
|
|
licenseKeyId: 'lk1',
|
|
startMinutes: '360',
|
|
endMinutes: '480',
|
|
proclamationIds: '[]'
|
|
}),
|
|
locals
|
|
} as never);
|
|
|
|
expect(isActionFailure(result)).toBe(true);
|
|
if (isActionFailure(result)) {
|
|
expect(result.status).toBe(400);
|
|
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
|
|
}
|
|
});
|
|
|
|
it('createBlock parses the payload and succeeds', async () => {
|
|
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
|
|
|
|
const result = await actions.createBlock!({
|
|
request: createFormRequest({
|
|
licenseKeyId: 'lk1',
|
|
day: 'mon',
|
|
startMinutes: '360',
|
|
endMinutes: '480',
|
|
proclamationIds: JSON.stringify(['p1', 'p2']),
|
|
dynamicAudio: 'true'
|
|
}),
|
|
locals
|
|
} as never);
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(createBlockMock).toHaveBeenCalledWith('lk1', {
|
|
day: 'mon',
|
|
startMinutes: 360,
|
|
endMinutes: 480,
|
|
proclamationIds: ['p1', 'p2'],
|
|
dynamicAudio: true
|
|
});
|
|
});
|
|
|
|
it('setType requiring confirmation returns the confirm error', async () => {
|
|
setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' });
|
|
|
|
const result = await actions.setType!({
|
|
request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }),
|
|
locals
|
|
} as never);
|
|
|
|
expect(isActionFailure(result)).toBe(true);
|
|
if (isActionFailure(result)) {
|
|
expect(result.data).toEqual({ error: 'confirm' });
|
|
}
|
|
});
|
|
|
|
it('deleteBlock returns not found without an id', async () => {
|
|
const result = await actions.deleteBlock!({
|
|
request: createFormRequest({ licenseKeyId: 'lk1' }),
|
|
locals
|
|
} as never);
|
|
|
|
expect(isActionFailure(result)).toBe(true);
|
|
if (isActionFailure(result)) {
|
|
expect(result.data).toEqual({ error: 'Block not found.' });
|
|
}
|
|
expect(deleteBlockMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|