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.
This commit is contained in:
@@ -6,23 +6,33 @@ vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamationCategory: { findMany: vi.fn() },
|
||||
proclamationGroup: { findMany: vi.fn() },
|
||||
proclamation: { findMany: vi.fn() }
|
||||
proclamation: { findMany: vi.fn() },
|
||||
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/schedule', () => ({
|
||||
listBlocks: vi.fn(),
|
||||
getScheduleForLicense: vi.fn(),
|
||||
setScheduleType: vi.fn(),
|
||||
createBlock: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
deleteBlock: vi.fn()
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
|
||||
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 listBlocksMock = listBlocks 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>;
|
||||
|
||||
@@ -37,18 +47,24 @@ beforeEach(() => {
|
||||
{ id: 1, name: 'General' }
|
||||
]);
|
||||
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
|
||||
listBlocksMock.mockResolvedValue([]);
|
||||
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() } as never)).rejects.toMatchObject({
|
||||
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 } as never);
|
||||
await load({ locals, url: new URL('http://x/') } as never);
|
||||
|
||||
expect(procFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -58,34 +74,37 @@ describe('schedule load', () => {
|
||||
});
|
||||
|
||||
it('maps blocks to title/category/group only', async () => {
|
||||
listBlocksMock.mockResolvedValue([
|
||||
{
|
||||
id: 'b1',
|
||||
startMinutes: 360,
|
||||
endMinutes: 480,
|
||||
dynamicAudio: true,
|
||||
proclamations: [
|
||||
{
|
||||
proclamation: {
|
||||
id: 'p1',
|
||||
name: 'Peace',
|
||||
categoryId: 2,
|
||||
groupId: 1,
|
||||
content: '<p>secret</p>'
|
||||
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 } as never)) as {
|
||||
blocks: {
|
||||
id: string;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: Record<string, unknown>[];
|
||||
}[];
|
||||
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',
|
||||
@@ -112,6 +131,7 @@ describe('schedule actions', () => {
|
||||
|
||||
const result = await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
licenseKeyId: 'lk1',
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: '[]'
|
||||
@@ -131,6 +151,8 @@ describe('schedule actions', () => {
|
||||
|
||||
const result = await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
licenseKeyId: 'lk1',
|
||||
day: 'mon',
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: JSON.stringify(['p1', 'p2']),
|
||||
@@ -140,7 +162,8 @@ describe('schedule actions', () => {
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
|
||||
expect(createBlockMock).toHaveBeenCalledWith('lk1', {
|
||||
day: 'mon',
|
||||
startMinutes: 360,
|
||||
endMinutes: 480,
|
||||
proclamationIds: ['p1', 'p2'],
|
||||
@@ -148,27 +171,23 @@ describe('schedule actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
|
||||
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
|
||||
it('setType requiring confirmation returns the confirm error', async () => {
|
||||
setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' });
|
||||
|
||||
await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: JSON.stringify(['p1'])
|
||||
}),
|
||||
const result = await actions.setType!({
|
||||
request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(createBlockMock).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ dynamicAudio: false })
|
||||
);
|
||||
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({}),
|
||||
request: createFormRequest({ licenseKeyId: 'lk1' }),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user