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:
@@ -3,6 +3,7 @@ import {
|
||||
blockDuration,
|
||||
blockSegments,
|
||||
findOverlap,
|
||||
findScheduleOverlap,
|
||||
fiveMinuteOptions,
|
||||
formatTime,
|
||||
hmToMinutes,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
snapMinutes,
|
||||
toHm,
|
||||
validateBlock,
|
||||
validateBlockShape,
|
||||
validateSchedule,
|
||||
type BlockInput,
|
||||
type BlockSibling
|
||||
} from './schedule-utils';
|
||||
@@ -282,3 +285,97 @@ describe('validateBlock', () => {
|
||||
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Schedule-wide validation (daily + weekly) ─────────────────────────
|
||||
|
||||
const block = (
|
||||
startMinutes: number,
|
||||
endMinutes: number,
|
||||
day: string | null = null,
|
||||
proclamationIds: string[] = ['p1']
|
||||
) => ({ day, startMinutes, endMinutes, proclamationIds });
|
||||
|
||||
describe('validateBlockShape', () => {
|
||||
it('rejects start == end', () => {
|
||||
expect(validateBlockShape(block(360, 360))).toEqual({
|
||||
ok: false,
|
||||
error: "Start and end time can't be the same."
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty block', () => {
|
||||
expect(validateBlockShape(block(360, 420, null, []))).toEqual({
|
||||
ok: false,
|
||||
error: 'Select at least one proclamation.'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a single-minute block', () => {
|
||||
expect(validateBlockShape(block(360, 361))).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSchedule daily', () => {
|
||||
it('accepts adjacent blocks (touching boundaries)', () => {
|
||||
expect(validateSchedule('daily', [block(360, 420), block(420, 510)])).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts a single-minute block', () => {
|
||||
expect(validateSchedule('daily', [block(360, 361)])).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects overlapping blocks', () => {
|
||||
expect(validateSchedule('daily', [block(360, 480), block(420, 540)]).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a midnight-crossing block that spills into an early block', () => {
|
||||
// 21:00→09:00 covers [0,540) on the next day; 08:00→08:30 overlaps.
|
||||
expect(validateSchedule('daily', [block(1260, 540, null), block(480, 510, null)]).ok).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a crossing block and its exact complement', () => {
|
||||
expect(validateSchedule('daily', [block(1260, 540, null), block(540, 1260, null)])).toEqual({
|
||||
ok: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSchedule weekly', () => {
|
||||
it('applies rules per day', () => {
|
||||
expect(validateSchedule('weekly', [block(360, 420, 'mon'), block(360, 420, 'tue')])).toEqual({
|
||||
ok: true
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects overlaps within the same day', () => {
|
||||
expect(validateSchedule('weekly', [block(360, 480, 'wed'), block(420, 540, 'wed')]).ok).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Sunday midnight-crossing spilling into Monday', () => {
|
||||
expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(0, 120, 'mon')]).ok).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a Sunday crossing block adjacent to a Monday block', () => {
|
||||
expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(60, 120, 'mon')])).toEqual({
|
||||
ok: true
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a Saturday crossing spilling into Sunday', () => {
|
||||
expect(validateSchedule('weekly', [block(1380, 60, 'sat'), block(30, 90, 'sun')]).ok).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findScheduleOverlap', () => {
|
||||
it('returns null for gaps', () => {
|
||||
expect(findScheduleOverlap('daily', [block(360, 420), block(600, 660)])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user