Files
portal/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts
T
NeilClack 3bb8e0189b 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.
2026-09-29 10:23:10 -05:00

106 lines
3.2 KiB
TypeScript

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);
});
});