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:
@@ -1,11 +1,21 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
|
||||
import {
|
||||
createBlock,
|
||||
deleteBlock,
|
||||
getScheduleForLicense,
|
||||
setScheduleType,
|
||||
updateBlock
|
||||
} from '$lib/server/schedule';
|
||||
|
||||
const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
function parseInput(formData: FormData) {
|
||||
const startMinutes = Number(formData.get('startMinutes'));
|
||||
const endMinutes = Number(formData.get('endMinutes'));
|
||||
const dayRaw = formData.get('day')?.toString() ?? '';
|
||||
const day = WEEK_DAYS.includes(dayRaw) ? dayRaw : null;
|
||||
let proclamationIds: string[] = [];
|
||||
const raw = formData.get('proclamationIds');
|
||||
if (typeof raw === 'string' && raw.length > 0) {
|
||||
@@ -19,16 +29,29 @@ function parseInput(formData: FormData) {
|
||||
}
|
||||
}
|
||||
const dynamicAudio = formData.get('dynamicAudio') === 'true';
|
||||
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
||||
return { day, startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
/** Ensure the given License Key belongs to the signed-in account. */
|
||||
async function ownedLicense(userId: string, licenseKeyId: string) {
|
||||
const license = await prisma.licenseKey.findFirst({
|
||||
where: { id: licenseKeyId, userId },
|
||||
select: { id: true, key: true, systemName: true }
|
||||
});
|
||||
return license;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const [blocks, categories, groups, proclamations] = await Promise.all([
|
||||
listBlocks(locals.user.id),
|
||||
const [licenses, categories, groups, proclamations] = await Promise.all([
|
||||
prisma.licenseKey.findMany({
|
||||
where: { userId: locals.user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, key: true, systemName: true, active: true }
|
||||
}),
|
||||
prisma.proclamationCategory.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
|
||||
@@ -45,27 +68,75 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
})
|
||||
]);
|
||||
|
||||
const blockViews = blocks.map((block) => ({
|
||||
id: block.id,
|
||||
startMinutes: block.startMinutes,
|
||||
endMinutes: block.endMinutes,
|
||||
dynamicAudio: block.dynamicAudio,
|
||||
proclamations: block.proclamations.map((entry) => ({
|
||||
id: entry.proclamation.id,
|
||||
name: entry.proclamation.name,
|
||||
categoryId: entry.proclamation.categoryId,
|
||||
groupId: entry.proclamation.groupId
|
||||
}))
|
||||
}));
|
||||
const requested = url.searchParams.get('license');
|
||||
const selected = licenses.find((l) => l.id === requested) ?? licenses[0] ?? null;
|
||||
|
||||
return { blocks: blockViews, categories, groups, proclamations };
|
||||
let type: 'daily' | 'weekly' | null = null;
|
||||
let blocks: {
|
||||
id: string;
|
||||
day: string | null;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
|
||||
}[] = [];
|
||||
|
||||
if (selected) {
|
||||
const schedule = await getScheduleForLicense(selected.id);
|
||||
if (schedule) {
|
||||
type = schedule.type === 'daily' || schedule.type === 'weekly' ? schedule.type : null;
|
||||
blocks = schedule.blocks.map((block) => ({
|
||||
id: block.id,
|
||||
day: block.day,
|
||||
startMinutes: block.startMinutes,
|
||||
endMinutes: block.endMinutes,
|
||||
dynamicAudio: block.dynamicAudio,
|
||||
proclamations: block.proclamations.map((entry) => ({
|
||||
id: entry.proclamation.id,
|
||||
name: entry.proclamation.name,
|
||||
categoryId: entry.proclamation.categoryId,
|
||||
groupId: entry.proclamation.groupId
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
licenses,
|
||||
selectedLicenseId: selected?.id ?? null,
|
||||
type,
|
||||
blocks,
|
||||
categories,
|
||||
groups,
|
||||
proclamations
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
setType: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const formData = await request.formData();
|
||||
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||
const rawType = formData.get('type')?.toString() ?? '';
|
||||
const type = rawType === 'daily' || rawType === 'weekly' ? rawType : null;
|
||||
const confirm = formData.get('confirm') === 'true';
|
||||
|
||||
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||
if (!license) return fail(404, { error: 'System not found.' });
|
||||
|
||||
const result = await setScheduleType(licenseKeyId, type, { confirmed: confirm });
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
createBlock: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const input = parseInput(await request.formData());
|
||||
const result = await createBlock(locals.user.id, input);
|
||||
const formData = await request.formData();
|
||||
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||
if (!license) return fail(404, { error: 'System not found.' });
|
||||
|
||||
const result = await createBlock(licenseKeyId, parseInput(formData));
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
},
|
||||
@@ -73,18 +144,27 @@ export const actions: Actions = {
|
||||
updateBlock: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const formData = await request.formData();
|
||||
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||
const id = formData.get('id')?.toString();
|
||||
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||
if (!license) return fail(404, { error: 'System not found.' });
|
||||
if (!id) return fail(400, { error: 'Block not found.' });
|
||||
const result = await updateBlock(locals.user.id, id, parseInput(formData));
|
||||
|
||||
const result = await updateBlock(licenseKeyId, id, parseInput(formData));
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteBlock: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const id = (await request.formData()).get('id')?.toString();
|
||||
const formData = await request.formData();
|
||||
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||
const id = formData.get('id')?.toString();
|
||||
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||
if (!license) return fail(404, { error: 'System not found.' });
|
||||
if (!id) return fail(400, { error: 'Block not found.' });
|
||||
const result = await deleteBlock(locals.user.id, id);
|
||||
|
||||
const result = await deleteBlock(licenseKeyId, id);
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
|
||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||
import { buildSchedulePayload, type V2ScheduleType } from '$lib/server/v2-serializers';
|
||||
|
||||
function scheduleType(value: string | null | undefined): V2ScheduleType {
|
||||
return value === 'daily' || value === 'weekly' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiosk-facing, key-only schedule read. Returns the target payload with an ETag
|
||||
* (the content-derived `version`); a matching `If-None-Match` yields 304. An
|
||||
* unknown License Key is 404; a key with no Schedule returns `type: null`.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, params }) => {
|
||||
const ip = clientIp(request);
|
||||
const rl = rateLimit({ key: `license-schedule:${ip}`, limit: 10, windowMs: 60_000 });
|
||||
if (!rl.ok) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
const licenseKey = (params.licenseKey ?? '').trim();
|
||||
const license = await prisma.licenseKey.findUnique({
|
||||
where: { key: licenseKey },
|
||||
select: { id: true }
|
||||
});
|
||||
if (!license) {
|
||||
throw error(404, 'unknown_license');
|
||||
}
|
||||
|
||||
const schedule = await getScheduleForLicense(license.id);
|
||||
const type = scheduleType(schedule?.type);
|
||||
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
|
||||
const payload = buildSchedulePayload(type, blocks);
|
||||
const etag = `"${payload.version}"`;
|
||||
|
||||
if (request.headers.get('if-none-match') === etag) {
|
||||
return new Response(null, { status: 304, headers: { etag } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json', etag }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
licenseKey: { findUnique: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/schedule', () => ({
|
||||
getScheduleForLicense: vi.fn(),
|
||||
scheduleBlocksToInput: vi.fn()
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||
import { GET } from './+server';
|
||||
|
||||
type GETEvent = Parameters<typeof GET>[0];
|
||||
|
||||
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockGetSchedule = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||
const mockToInput = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
|
||||
|
||||
const KEY = 'ABCD-EFGH-IJKL-MNOP';
|
||||
|
||||
function event(ifNoneMatch?: string): GETEvent {
|
||||
const headers = new Headers();
|
||||
if (ifNoneMatch) headers.set('if-none-match', ifNoneMatch);
|
||||
return {
|
||||
request: new Request(`http://localhost/api/v2/licenses/${KEY}/schedule`, { headers }),
|
||||
params: { licenseKey: KEY }
|
||||
} as GETEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_clearRateLimits();
|
||||
});
|
||||
|
||||
describe('GET /api/v2/licenses/[licenseKey]/schedule', () => {
|
||||
it('returns 404 for an unknown license', async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
await expect(GET(event())).rejects.toSatisfy(
|
||||
(e: unknown) => isHttpError(e) && e.status === 404 && e.body.message === 'unknown_license'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns type null with empty lists when there is no schedule', async () => {
|
||||
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||
mockGetSchedule.mockResolvedValue(null);
|
||||
|
||||
const res = await GET(event());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.type).toBeNull();
|
||||
expect(body.daily).toEqual([]);
|
||||
expect(body.blocks).toEqual([]);
|
||||
expect(body.version).toBeTruthy();
|
||||
expect(res.headers.get('etag')).toBe(`"${body.version}"`);
|
||||
});
|
||||
|
||||
it('returns the daily payload with an ETag', async () => {
|
||||
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||
mockGetSchedule.mockResolvedValue({ type: 'daily' });
|
||||
mockToInput.mockReturnValue([
|
||||
{
|
||||
id: 'b1',
|
||||
day: null,
|
||||
startMinutes: 360,
|
||||
endMinutes: 420,
|
||||
dynamicAudio: true,
|
||||
proclamationIds: ['p1']
|
||||
}
|
||||
]);
|
||||
|
||||
const res = await GET(event());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { type: string; daily: unknown[]; blocks: unknown[] };
|
||||
expect(body.type).toBe('daily');
|
||||
expect(body.daily).toHaveLength(1);
|
||||
expect(body.blocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 304 when If-None-Match matches the ETag', async () => {
|
||||
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||
mockGetSchedule.mockResolvedValue(null);
|
||||
|
||||
const first = await GET(event());
|
||||
const etag = first.headers.get('etag') as string;
|
||||
const second = await GET(event(etag));
|
||||
|
||||
expect(second.status).toBe(304);
|
||||
expect(second.headers.get('etag')).toBe(etag);
|
||||
});
|
||||
|
||||
it('returns 200 when If-None-Match does not match', async () => {
|
||||
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||
mockGetSchedule.mockResolvedValue(null);
|
||||
|
||||
const res = await GET(event('"stale"'));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,24 @@ import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
|
||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||
import { listBlocks } from '$lib/server/schedule';
|
||||
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
|
||||
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||
import { buildSchedulePayload } from '$lib/server/v2-serializers';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function scheduleType(value: string | null | undefined) {
|
||||
return value === 'daily' || value === 'weekly' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
|
||||
* `email` + `licenseKey` query parameters and validates only that the pair
|
||||
* belong together — license active/entitlement is deliberately not checked
|
||||
* here, since an invalid license would already lock the kiosk out.
|
||||
*
|
||||
* Extended additively with `version`, `type`, `daily`, and `weekly`. `email`
|
||||
* and `blocks` keep their exact previous meaning (daily flattening; empty for
|
||||
* weekly), so old Kiosks are unaffected. No ETag/304 here.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const ip = clientIp(request);
|
||||
@@ -50,14 +58,10 @@ export const GET: RequestHandler = async ({ request, url }) => {
|
||||
throw error(400, "this key doesn't appear to belong to you");
|
||||
}
|
||||
|
||||
const blocks = await listBlocks(user.id);
|
||||
const schedule = await getScheduleForLicense(storedKey.id);
|
||||
const type = scheduleType(schedule?.type);
|
||||
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
|
||||
const payload = buildSchedulePayload(type, blocks);
|
||||
|
||||
const serialized = blocks.map((block) =>
|
||||
serializeScheduleBlock(
|
||||
block,
|
||||
block.proclamations.map((entry) => entry.proclamation.id)
|
||||
)
|
||||
);
|
||||
|
||||
return jsonOk({ email, blocks: serialized });
|
||||
return jsonOk({ email, ...payload });
|
||||
};
|
||||
|
||||
@@ -3,19 +3,24 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
schedule: { findUnique: vi.fn(), create: vi.fn() },
|
||||
block: { findMany: vi.fn() },
|
||||
licenseKey: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/schedule', () => ({
|
||||
getScheduleForLicense: vi.fn(),
|
||||
scheduleBlocksToInput: vi.fn()
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||
import { GET } from './+server';
|
||||
|
||||
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
|
||||
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||
const toInputMock = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
|
||||
|
||||
function call(params: Record<string, string>) {
|
||||
const url = new URL('http://localhost/api/v2/schedule');
|
||||
@@ -29,7 +34,8 @@ const KEY = 'AAAA-BBBB-CCCC-DDDD';
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_clearRateLimits();
|
||||
blockFindMany.mockResolvedValue([]);
|
||||
getScheduleMock.mockResolvedValue(null);
|
||||
toInputMock.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe('GET /api/v2/schedule auth', () => {
|
||||
@@ -50,7 +56,7 @@ describe('GET /api/v2/schedule auth', () => {
|
||||
});
|
||||
|
||||
it('rejects an unknown user', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
|
||||
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
|
||||
userFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||
@@ -60,7 +66,12 @@ describe('GET /api/v2/schedule auth', () => {
|
||||
});
|
||||
|
||||
it('rejects a key that does not belong to the email account', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
|
||||
licenseKeyFindUnique.mockResolvedValue({
|
||||
id: 'lk1',
|
||||
key: KEY,
|
||||
userId: 'someone-else',
|
||||
email: null
|
||||
});
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
|
||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||
@@ -88,52 +99,62 @@ describe('GET /api/v2/schedule auth', () => {
|
||||
describe('GET /api/v2/schedule success', () => {
|
||||
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({
|
||||
id: 'lk1',
|
||||
key: KEY,
|
||||
userId: 'user-1',
|
||||
email: null,
|
||||
active: false
|
||||
});
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
blockFindMany.mockResolvedValue([
|
||||
getScheduleMock.mockResolvedValue({ type: 'daily' });
|
||||
toInputMock.mockReturnValue([
|
||||
{
|
||||
id: 'block-1',
|
||||
day: null,
|
||||
startMinutes: 21 * 60,
|
||||
endMinutes: 9 * 60,
|
||||
dynamicAudio: true,
|
||||
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
|
||||
proclamationIds: ['p1', 'p2']
|
||||
}
|
||||
]);
|
||||
|
||||
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
email: 'jane@example.com',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
startTime: '21:00',
|
||||
startMinutes: 1260,
|
||||
endTime: '09:00',
|
||||
endMinutes: 540,
|
||||
overnight: true,
|
||||
durationMinutes: 720,
|
||||
dynamicAudio: true,
|
||||
proclamationIds: ['p1', 'p2']
|
||||
}
|
||||
]
|
||||
});
|
||||
const body = (await response.json()) as { email: string; blocks: unknown[]; type: string };
|
||||
expect(body.email).toBe('jane@example.com');
|
||||
expect(body.blocks).toEqual([
|
||||
{
|
||||
id: 'block-1',
|
||||
startTime: '21:00',
|
||||
startMinutes: 1260,
|
||||
endTime: '09:00',
|
||||
endMinutes: 540,
|
||||
overnight: true,
|
||||
durationMinutes: 720,
|
||||
dynamicAudio: true,
|
||||
proclamationIds: ['p1', 'p2']
|
||||
}
|
||||
]);
|
||||
// Additive fields for new Kiosks.
|
||||
expect(body.type).toBe('daily');
|
||||
expect(body).toHaveProperty('version');
|
||||
expect(body).toHaveProperty('daily');
|
||||
expect(body).toHaveProperty('weekly');
|
||||
});
|
||||
|
||||
it('returns IDs for every selected proclamation regardless of frequency', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
|
||||
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
blockFindMany.mockResolvedValue([
|
||||
getScheduleMock.mockResolvedValue({ type: 'daily' });
|
||||
toInputMock.mockReturnValue([
|
||||
{
|
||||
id: 'block-1',
|
||||
day: null,
|
||||
startMinutes: 0,
|
||||
endMinutes: 60,
|
||||
proclamations: [{ proclamation: { id: 'p1' } }]
|
||||
dynamicAudio: false,
|
||||
proclamationIds: ['p1']
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -157,6 +178,7 @@ describe('GET /api/v2/schedule success', () => {
|
||||
|
||||
it('accepts the legacy `key` query alias and an email-only license', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({
|
||||
id: 'lk1',
|
||||
key: KEY,
|
||||
userId: null,
|
||||
email: 'jane@example.com'
|
||||
@@ -166,6 +188,9 @@ describe('GET /api/v2/schedule success', () => {
|
||||
const response = await call({ email: 'jane@example.com', key: KEY });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
|
||||
const body = (await response.json()) as { email: string; blocks: unknown[]; type: null };
|
||||
expect(body.email).toBe('jane@example.com');
|
||||
expect(body.blocks).toEqual([]);
|
||||
expect(body.type).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user