Files
dyolink/backend/src/common/zoned-civil-time.spec.ts
Amin Mousavi f3fb8736ab fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.

"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.

The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.

- a non-object `due` (the model emitting a bare string) was treated as "no
  deadline spoken" and silently discarded; only null/undefined mean absent now,
  anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
  table, contradicting the module's own "degrade to null" contract; they now
  return false / 0, which also makes isValidJalaliDate's day check naturally
  false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
  RangeError before any fallback; it now validates and backstops to UTC, so a
  bad zone costs at most a day rather than a 500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30

72 lines
2.6 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 { appointmentWithinWorkingHours } from './working-hours';
import {
civilDateInZone,
isValidIanaTimeZone,
zonedWeekdayAndMinutes,
} from './zoned-civil-time';
describe('zoned civil time', () => {
it('accepts IANA zones and rejects garbage', () => {
expect(isValidIanaTimeZone('Asia/Tehran')).toBe(true);
expect(isValidIanaTimeZone('Europe/Amsterdam')).toBe(true);
expect(isValidIanaTimeZone('Not/AZone')).toBe(false);
});
it('maps a UTC instant to Tehran wall-clock (no DST)', () => {
// 2026-08-20 09:45 Asia/Tehran = 06:15 UTC (the production 400 case)
const instant = new Date('2026-08-20T06:15:00.000Z');
const zoned = zonedWeekdayAndMinutes(instant, 'Asia/Tehran');
expect(zoned.jsWeekday).toBe(4); // Thursday
expect(zoned.minuteOfDay).toBe(9 * 60 + 45);
});
it('treats morning Tehran slots as inside 08:0017:00 hours', () => {
const start = new Date('2026-08-20T06:15:00.000Z');
const end = new Date('2026-08-20T06:45:00.000Z');
expect(
appointmentWithinWorkingHours(
start,
end,
[{ startMinute: 8 * 60, endMinute: 17 * 60 }],
'Asia/Tehran',
),
).toBe(true);
expect(
appointmentWithinWorkingHours(
start,
end,
[{ startMinute: 8 * 60, endMinute: 17 * 60 }],
'UTC',
),
).toBe(false);
});
describe('civilDateInZone', () => {
it('gives the local civil date, which can differ from the UTC date', () => {
// 21:30 UTC is already the next day in Tehran (+03:30).
const instant = new Date('2025-10-11T21:30:00.000Z');
expect(civilDateInZone(instant, 'UTC')).toBe('2025-10-11');
expect(civilDateInZone(instant, 'Asia/Tehran')).toBe('2025-10-12');
});
it('gives the previous day for zones behind UTC just after midnight', () => {
const instant = new Date('2025-10-11T02:00:00.000Z');
expect(civilDateInZone(instant, 'America/New_York')).toBe('2025-10-10');
expect(civilDateInZone(instant, 'Europe/Amsterdam')).toBe('2025-10-11');
});
it('falls back to UTC on an invalid zone rather than throwing', () => {
// Intl raises RangeError on an unknown zone and this takes a client-supplied string.
const instant = new Date('2025-10-11T21:30:00.000Z');
expect(() => civilDateInZone(instant, 'Not/AZone')).not.toThrow();
expect(civilDateInZone(instant, 'Not/AZone')).toBe('2025-10-11');
expect(civilDateInZone(instant, '')).toBe('2025-10-11');
});
it('zero-pads single-digit months and days', () => {
expect(civilDateInZone(new Date('2025-01-05T12:00:00.000Z'), 'UTC')).toBe(
'2025-01-05',
);
});
});
});