621ce942b5
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
239 lines
8.9 KiB
JavaScript
239 lines
8.9 KiB
JavaScript
/**
|
||
* Unit tests for the per-weekday business-hours floor (migration 114).
|
||
* Exercises the pure snap logic against a fixed IANA zone so the results
|
||
* don't drift with the CI box's local timezone.
|
||
*
|
||
* All scenarios use Europe/Zurich (the regulatory-scope default).
|
||
*/
|
||
|
||
const {
|
||
snapToBusinessHours,
|
||
parseHHMM,
|
||
minutesToHHMM,
|
||
normaliseSchedule,
|
||
hasAnyBlocks,
|
||
_internal,
|
||
} = require('../../src/utils/businessHours');
|
||
|
||
const TZ = 'Europe/Zurich';
|
||
|
||
// Mon–Fri 09:00–18:00, weekend closed. Plain string-block storage shape.
|
||
const STANDARD = {
|
||
'1': [{ start: '09:00', end: '18:00' }],
|
||
'2': [{ start: '09:00', end: '18:00' }],
|
||
'3': [{ start: '09:00', end: '18:00' }],
|
||
'4': [{ start: '09:00', end: '18:00' }],
|
||
'5': [{ start: '09:00', end: '18:00' }],
|
||
'6': [],
|
||
'7': [],
|
||
};
|
||
|
||
// Mon–Fri with a lunch break (09:00–12:00, 13:00–18:00).
|
||
const LUNCH = {
|
||
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||
'6': [],
|
||
'7': [],
|
||
};
|
||
|
||
const cfg = (schedule, overrides = {}) => ({
|
||
enabled: true,
|
||
timezone: TZ,
|
||
schedule,
|
||
...overrides,
|
||
});
|
||
|
||
// Build a UTC instant from a Zurich wall-clock so the assertions read in
|
||
// local terms. Reuses the module's own converter (covered separately).
|
||
function zurich(y, mo, d, hh, mi) {
|
||
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
|
||
}
|
||
|
||
function partsOf(date) {
|
||
const p = _internal.getZonedParts(date, TZ);
|
||
return [p.y, p.mo, p.d, p.hh, p.mi];
|
||
}
|
||
|
||
describe('parseHHMM / minutesToHHMM', () => {
|
||
it('parses valid times to minutes', () => {
|
||
expect(parseHHMM('09:00')).toBe(540);
|
||
expect(parseHHMM('00:00')).toBe(0);
|
||
expect(parseHHMM('23:59')).toBe(1439);
|
||
});
|
||
it('rejects malformed input', () => {
|
||
expect(parseHHMM('9:00')).toBeNull();
|
||
expect(parseHHMM('24:00')).toBeNull();
|
||
expect(parseHHMM('12:60')).toBeNull();
|
||
expect(parseHHMM('')).toBeNull();
|
||
expect(parseHHMM(null)).toBeNull();
|
||
});
|
||
it('round-trips minutesToHHMM', () => {
|
||
expect(minutesToHHMM(540)).toBe('09:00');
|
||
expect(minutesToHHMM(0)).toBe('00:00');
|
||
expect(minutesToHHMM(1439)).toBe('23:59');
|
||
});
|
||
});
|
||
|
||
describe('normaliseSchedule', () => {
|
||
it('parses, sorts, and drops invalid blocks', () => {
|
||
const out = normaliseSchedule({
|
||
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
|
||
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
|
||
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
|
||
});
|
||
expect(out['1']).toEqual([
|
||
{ start: '09:00', end: '12:00' },
|
||
{ start: '13:00', end: '18:00' },
|
||
]);
|
||
expect(out['2']).toEqual([]);
|
||
expect(out['3']).toEqual([]);
|
||
expect(out['7']).toEqual([]);
|
||
});
|
||
it('accepts [start,end] pair blocks and a JSON string', () => {
|
||
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
|
||
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
|
||
});
|
||
it('garbage input → all-empty week', () => {
|
||
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
|
||
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('snapToBusinessHours — single window (Mon–Fri 09:00–18:00)', () => {
|
||
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
|
||
// 2026-06-02 is a Tuesday.
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
|
||
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
|
||
});
|
||
|
||
it('weekday inside window (Tue 10:30) → unchanged', () => {
|
||
const input = zurich(2026, 6, 2, 10, 30);
|
||
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
|
||
});
|
||
|
||
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
|
||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||
});
|
||
|
||
it('Sunday 14:00 → Monday 09:00', () => {
|
||
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
|
||
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
|
||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||
});
|
||
|
||
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
|
||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||
});
|
||
|
||
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
|
||
// 2026-06-05 is a Friday.
|
||
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
|
||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||
});
|
||
|
||
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
|
||
const input = zurich(2026, 6, 2, 9, 0);
|
||
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
|
||
});
|
||
|
||
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
|
||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||
});
|
||
});
|
||
|
||
describe('snapToBusinessHours — lunch break (09:00–12:00, 13:00–18:00)', () => {
|
||
it('morning block (Tue 10:30) → unchanged', () => {
|
||
const input = zurich(2026, 6, 2, 10, 30);
|
||
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
|
||
});
|
||
|
||
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
|
||
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
|
||
});
|
||
|
||
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
|
||
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
|
||
});
|
||
|
||
it('afternoon block (Tue 17:59) → unchanged', () => {
|
||
const input = zurich(2026, 6, 2, 17, 59);
|
||
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
|
||
});
|
||
|
||
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
|
||
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
|
||
});
|
||
|
||
it('after close (Tue 19:00) → next day 09:00', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
|
||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||
});
|
||
});
|
||
|
||
describe('snapToBusinessHours — per-day differing hours', () => {
|
||
const PERDAY = {
|
||
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
|
||
'2': [], // Tue closed
|
||
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
|
||
'4': [], '5': [], '6': [], '7': [],
|
||
};
|
||
|
||
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
|
||
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
|
||
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
|
||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||
});
|
||
|
||
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
|
||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||
});
|
||
|
||
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
|
||
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
|
||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||
});
|
||
});
|
||
|
||
describe('snapToBusinessHours — passthrough cases', () => {
|
||
it('floor disabled → unchanged even when outside hours', () => {
|
||
const input = zurich(2026, 6, 2, 2, 11);
|
||
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
|
||
.toBe(input.getTime());
|
||
});
|
||
|
||
it('empty schedule → unchanged (nothing to snap to)', () => {
|
||
const empty = normaliseSchedule(null);
|
||
const input = zurich(2026, 6, 7, 14, 0);
|
||
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
|
||
});
|
||
|
||
it('non-Date / invalid input is passed through untouched', () => {
|
||
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
|
||
const bad = new Date('not-a-date');
|
||
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('_internal round-trips', () => {
|
||
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
|
||
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
|
||
const p = _internal.getZonedParts(d, TZ);
|
||
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
|
||
});
|
||
|
||
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
|
||
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
|
||
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
|
||
});
|
||
});
|