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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user