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.
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
blockDuration,
|
||
blockSegments,
|
||
findOverlap,
|
||
findScheduleOverlap,
|
||
fiveMinuteOptions,
|
||
formatTime,
|
||
hmToMinutes,
|
||
isOvernight,
|
||
parseTimeInput,
|
||
snapMinutes,
|
||
toHm,
|
||
validateBlock,
|
||
validateBlockShape,
|
||
validateSchedule,
|
||
type BlockInput,
|
||
type BlockSibling
|
||
} from './schedule-utils';
|
||
|
||
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
|
||
startMinutes: 6 * 60,
|
||
endMinutes: 8 * 60,
|
||
proclamationIds: ['p1'],
|
||
...overrides
|
||
});
|
||
|
||
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
|
||
id,
|
||
startMinutes,
|
||
endMinutes
|
||
});
|
||
|
||
describe('formatTime', () => {
|
||
it('formats 12-hour clock labels', () => {
|
||
expect(formatTime(0)).toBe('12:00 AM');
|
||
expect(formatTime(6 * 60)).toBe('6:00 AM');
|
||
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
|
||
expect(formatTime(12 * 60)).toBe('12:00 PM');
|
||
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
|
||
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
|
||
});
|
||
});
|
||
|
||
describe('toHm', () => {
|
||
it('formats 24-hour HH:MM strings', () => {
|
||
expect(toHm(0)).toBe('00:00');
|
||
expect(toHm(6 * 60)).toBe('06:00');
|
||
expect(toHm(23 * 60 + 59)).toBe('23:59');
|
||
});
|
||
});
|
||
|
||
describe('hmToMinutes', () => {
|
||
it('parses 24-hour HH:MM strings', () => {
|
||
expect(hmToMinutes('00:00')).toBe(0);
|
||
expect(hmToMinutes('07:05')).toBe(7 * 60 + 5);
|
||
expect(hmToMinutes('23:59')).toBe(23 * 60 + 59);
|
||
});
|
||
|
||
it('rejects malformed or out-of-range values', () => {
|
||
expect(hmToMinutes('7:05')).toBeNull();
|
||
expect(hmToMinutes('24:00')).toBeNull();
|
||
expect(hmToMinutes('12:60')).toBeNull();
|
||
expect(hmToMinutes('')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('parseTimeInput', () => {
|
||
it('parses tolerant 12-hour input', () => {
|
||
expect(parseTimeInput('7:05 pm')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
|
||
expect(parseTimeInput('7:05PM')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
|
||
expect(parseTimeInput('7pm')).toEqual({ ok: true, minutes: 19 * 60 });
|
||
expect(parseTimeInput('7:7')).toEqual({ ok: true, minutes: 7 * 60 + 7 });
|
||
expect(parseTimeInput('12:00 am')).toEqual({ ok: true, minutes: 0 });
|
||
expect(parseTimeInput('12:30 PM')).toEqual({ ok: true, minutes: 12 * 60 + 30 });
|
||
expect(parseTimeInput('11:59 pm')).toEqual({ ok: true, minutes: 23 * 60 + 59 });
|
||
expect(parseTimeInput('0:30')).toEqual({ ok: true, minutes: 30 });
|
||
});
|
||
|
||
it('rejects ambiguous or out-of-range input', () => {
|
||
expect(parseTimeInput('13:00 PM').ok).toBe(false);
|
||
expect(parseTimeInput('7:70').ok).toBe(false);
|
||
expect(parseTimeInput('25:00').ok).toBe(false);
|
||
expect(parseTimeInput('').ok).toBe(false);
|
||
expect(parseTimeInput('noon').ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('fiveMinuteOptions', () => {
|
||
it('lists every 5-minute step across the day', () => {
|
||
const options = fiveMinuteOptions();
|
||
expect(options[0]).toBe(0);
|
||
expect(options[1]).toBe(5);
|
||
expect(options[options.length - 1]).toBe(23 * 60 + 55);
|
||
expect(options).toHaveLength((24 * 60) / 5);
|
||
});
|
||
});
|
||
|
||
describe('snapMinutes', () => {
|
||
it('rounds to the nearest 15 minutes and clamps to the day', () => {
|
||
expect(snapMinutes(0)).toBe(0);
|
||
expect(snapMinutes(7)).toBe(0);
|
||
expect(snapMinutes(8)).toBe(15);
|
||
expect(snapMinutes(370)).toBe(375);
|
||
expect(snapMinutes(-10)).toBe(0);
|
||
expect(snapMinutes(2000)).toBe(1440);
|
||
});
|
||
});
|
||
|
||
describe('isOvernight', () => {
|
||
it('is true when the end clock time is before the start', () => {
|
||
expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true);
|
||
expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true);
|
||
});
|
||
|
||
it('is false for same-day ranges and a full-day range', () => {
|
||
expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false);
|
||
expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('blockDuration', () => {
|
||
it('counts a same-day range directly', () => {
|
||
expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120);
|
||
});
|
||
|
||
it('counts an overnight range as wrapping to the next day', () => {
|
||
expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720);
|
||
});
|
||
|
||
it('treats an end of midnight as wrapping from the start', () => {
|
||
expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120);
|
||
});
|
||
});
|
||
|
||
describe('blockSegments', () => {
|
||
it('returns a single segment for a same-day range', () => {
|
||
expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]);
|
||
});
|
||
|
||
it('splits an overnight range at midnight', () => {
|
||
expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([
|
||
[21 * 60, 1440],
|
||
[0, 9 * 60]
|
||
]);
|
||
});
|
||
|
||
it('returns no segments for a zero-length range', () => {
|
||
expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('findOverlap', () => {
|
||
it('returns null for adjacent ranges', () => {
|
||
expect(
|
||
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
|
||
).toBeNull();
|
||
});
|
||
|
||
it('returns the sibling for a partial overlap', () => {
|
||
const other = sibling(7 * 60, 9 * 60);
|
||
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
|
||
});
|
||
|
||
it('detects an overnight block overlapping an early-morning block', () => {
|
||
const other = sibling(8 * 60, 10 * 60);
|
||
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
|
||
});
|
||
|
||
it('detects an overnight block overlapping a late-evening block', () => {
|
||
const other = sibling(20 * 60, 22 * 60);
|
||
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
|
||
});
|
||
|
||
it('allows an overnight block and its exact complement (adjacent at both ends)', () => {
|
||
expect(
|
||
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)])
|
||
).toBeNull();
|
||
});
|
||
|
||
it('allows an overnight block and a midday block it does not touch', () => {
|
||
expect(
|
||
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)])
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('validateBlock', () => {
|
||
it('accepts a valid block with no siblings', () => {
|
||
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
|
||
});
|
||
|
||
it('rejects equal start and end times', () => {
|
||
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
|
||
expect(result).toEqual({
|
||
ok: false,
|
||
error: "Start and end time can't be the same."
|
||
});
|
||
});
|
||
|
||
it('rejects a time outside the 24-hour range', () => {
|
||
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
|
||
ok: false,
|
||
error: 'Enter a valid start and end time.'
|
||
});
|
||
});
|
||
|
||
it('rejects a negative start time', () => {
|
||
expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({
|
||
ok: false,
|
||
error: 'Enter a valid start and end time.'
|
||
});
|
||
});
|
||
|
||
it('accepts an overnight block', () => {
|
||
expect(
|
||
validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], {
|
||
isCreate: true
|
||
})
|
||
).toEqual({ ok: true });
|
||
});
|
||
|
||
it('rejects an overnight block that overlaps another and names it', () => {
|
||
const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [
|
||
sibling(8 * 60, 10 * 60)
|
||
]);
|
||
expect(result).toEqual({
|
||
ok: false,
|
||
error: 'This overlaps with your 8:00 AM–10:00 AM block.'
|
||
});
|
||
});
|
||
|
||
it('requires at least one proclamation', () => {
|
||
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
|
||
ok: false,
|
||
error: 'Select at least one proclamation.'
|
||
});
|
||
});
|
||
|
||
it('enforces the four-block maximum on create', () => {
|
||
const siblings = [
|
||
sibling(0, 60, 'a'),
|
||
sibling(60, 120, 'b'),
|
||
sibling(120, 180, 'c'),
|
||
sibling(180, 240, 'd')
|
||
];
|
||
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
|
||
isCreate: true
|
||
});
|
||
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
|
||
});
|
||
|
||
it('allows adjacent blocks (end exactly equals next start)', () => {
|
||
expect(
|
||
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
|
||
).toEqual({
|
||
ok: true
|
||
});
|
||
});
|
||
|
||
it('rejects an exact duplicate range and names the conflicting block', () => {
|
||
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
|
||
expect(result).toEqual({
|
||
ok: false,
|
||
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
|
||
});
|
||
});
|
||
|
||
it('rejects a partial overlap', () => {
|
||
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
|
||
sibling(8 * 60, 10 * 60)
|
||
]);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects a block that contains another block', () => {
|
||
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
|
||
sibling(6 * 60, 8 * 60)
|
||
]);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('does not apply the maximum when updating (isCreate false)', () => {
|
||
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
|
||
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();
|
||
});
|
||
});
|