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.
106 lines
3.2 KiB
TypeScript
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);
|
|
});
|
|
});
|