Files
portal/src/lib/schedule-utils.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

382 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});