feat(email): per-weekday business hours + manual queue flush
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.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user