Skip to content

Commit 55a38c2

Browse files
committed
test(github): verify user validation timezone normalization and calendar data boundaries
1 parent 2e604a1 commit 55a38c2

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// @vitest-environment node
2+
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
type SupportedTimezone = 'America/New_York' | 'Asia/Kolkata' | 'Asia/Tokyo' | 'UTC';
6+
7+
type CalendarPartType = 'day' | 'month' | 'year';
8+
9+
type VisualDateParts = Readonly<{
10+
year: number;
11+
month: number;
12+
day: number;
13+
hour: number;
14+
minute: number;
15+
second: number;
16+
}>;
17+
18+
type StructuredCalendarPart = Readonly<{
19+
type: CalendarPartType;
20+
value: string;
21+
}>;
22+
23+
type TimezoneTrackingLimit = Readonly<{
24+
minimumOffsetMinutes: number;
25+
maximumOffsetMinutes: number;
26+
}>;
27+
28+
const originalTimezone = process.env.TZ;
29+
30+
const timezoneTrackingLimits: Record<SupportedTimezone, TimezoneTrackingLimit> = {
31+
'America/New_York': {
32+
minimumOffsetMinutes: -300,
33+
maximumOffsetMinutes: -240,
34+
},
35+
'Asia/Kolkata': {
36+
minimumOffsetMinutes: 330,
37+
maximumOffsetMinutes: 330,
38+
},
39+
'Asia/Tokyo': {
40+
minimumOffsetMinutes: 540,
41+
maximumOffsetMinutes: 540,
42+
},
43+
UTC: {
44+
minimumOffsetMinutes: 0,
45+
maximumOffsetMinutes: 0,
46+
},
47+
};
48+
49+
beforeEach(() => {
50+
process.env.TZ = 'UTC';
51+
});
52+
53+
afterEach(() => {
54+
if (originalTimezone === undefined) {
55+
delete process.env.TZ;
56+
} else {
57+
process.env.TZ = originalTimezone;
58+
}
59+
60+
vi.clearAllMocks();
61+
vi.restoreAllMocks();
62+
vi.useRealTimers();
63+
});
64+
65+
function resolveTimezone(region?: string): SupportedTimezone {
66+
switch (region) {
67+
case 'America/New_York':
68+
case 'Asia/Kolkata':
69+
case 'Asia/Tokyo':
70+
case 'UTC':
71+
return region;
72+
default:
73+
return 'UTC';
74+
}
75+
}
76+
77+
function formatDateParts(date: Date, timeZone: SupportedTimezone): Intl.DateTimeFormatPart[] {
78+
return new Intl.DateTimeFormat('en-GB', {
79+
timeZone,
80+
year: 'numeric',
81+
month: '2-digit',
82+
day: '2-digit',
83+
hour: '2-digit',
84+
minute: '2-digit',
85+
second: '2-digit',
86+
hour12: false,
87+
hourCycle: 'h23',
88+
}).formatToParts(date);
89+
}
90+
91+
function getPartValue(
92+
parts: Intl.DateTimeFormatPart[],
93+
type: Intl.DateTimeFormatPart['type']
94+
): string {
95+
const match = parts.find((part) => part.type === type);
96+
97+
if (match === undefined) {
98+
throw new Error(`Missing date part: ${type}`);
99+
}
100+
101+
return match.value;
102+
}
103+
104+
function toVisualDateParts(date: Date, timeZone: SupportedTimezone): VisualDateParts {
105+
const parts = formatDateParts(date, timeZone);
106+
107+
return {
108+
year: Number.parseInt(getPartValue(parts, 'year'), 10),
109+
month: Number.parseInt(getPartValue(parts, 'month'), 10),
110+
day: Number.parseInt(getPartValue(parts, 'day'), 10),
111+
hour: Number.parseInt(getPartValue(parts, 'hour'), 10),
112+
minute: Number.parseInt(getPartValue(parts, 'minute'), 10),
113+
second: Number.parseInt(getPartValue(parts, 'second'), 10),
114+
};
115+
}
116+
117+
function toVisualDateKey(date: Date, timeZone: SupportedTimezone): string {
118+
const parts = toVisualDateParts(date, timeZone);
119+
120+
return `${parts.year.toString().padStart(4, '0')}-${parts.month.toString().padStart(2, '0')}-${parts.day
121+
.toString()
122+
.padStart(2, '0')}`;
123+
}
124+
125+
function getObservedOffsetMinutes(date: Date, timeZone: SupportedTimezone): number {
126+
const parts = toVisualDateParts(date, timeZone);
127+
const derivedUtcMillis = Date.UTC(
128+
parts.year,
129+
parts.month - 1,
130+
parts.day,
131+
parts.hour,
132+
parts.minute,
133+
parts.second
134+
);
135+
136+
return Math.round((derivedUtcMillis - date.getTime()) / 60_000);
137+
}
138+
139+
function buildMonthlyVisualGrid(
140+
year: number,
141+
monthIndex: number,
142+
timeZone: SupportedTimezone
143+
): string[] {
144+
const dayCount = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
145+
146+
return Array.from({ length: dayCount }, (_value, index) => {
147+
const sourceDate = new Date(Date.UTC(year, monthIndex, index + 1, 12, 0, 0));
148+
return toVisualDateKey(sourceDate, timeZone);
149+
});
150+
}
151+
152+
function projectStructuredCalendarParts(
153+
date: Date,
154+
timeZone: SupportedTimezone
155+
): StructuredCalendarPart[] {
156+
const parts = formatDateParts(date, timeZone);
157+
158+
return parts
159+
.filter((part): part is Intl.DateTimeFormatPart & { type: CalendarPartType } => {
160+
return part.type === 'day' || part.type === 'month' || part.type === 'year';
161+
})
162+
.map((part) => ({
163+
type: part.type,
164+
value: part.value,
165+
}));
166+
}
167+
168+
function isOffsetWithinLimit(timeZone: SupportedTimezone, offsetMinutes: number): boolean {
169+
const limit = timezoneTrackingLimits[timeZone];
170+
return offsetMinutes >= limit.minimumOffsetMinutes && offsetMinutes <= limit.maximumOffsetMinutes;
171+
}
172+
173+
describe('validate-user timezone boundaries', () => {
174+
it('maps the same instant to the expected visual dates in New York, Kolkata, and Tokyo', () => {
175+
const instant = new Date('2024-12-31T23:30:00.000Z');
176+
177+
expect(toVisualDateKey(instant, 'America/New_York')).toBe('2024-12-31');
178+
expect(toVisualDateKey(instant, 'Asia/Kolkata')).toBe('2025-01-01');
179+
expect(toVisualDateKey(instant, 'Asia/Tokyo')).toBe('2025-01-01');
180+
});
181+
182+
it('keeps leap-day grids contiguous across February 29 in Asia/Kolkata', () => {
183+
const grid = buildMonthlyVisualGrid(2024, 1, 'Asia/Kolkata');
184+
185+
expect(grid).toHaveLength(29);
186+
expect(grid[27]).toBe('2024-02-28');
187+
expect(grid[28]).toBe('2024-02-29');
188+
expect(new Set(grid).size).toBe(29);
189+
});
190+
191+
it('formats localized calendar parts through stable structured arrays', () => {
192+
const instant = new Date('2024-02-28T18:45:00.000Z');
193+
const parts = projectStructuredCalendarParts(instant, 'Asia/Kolkata');
194+
195+
expect(parts).toEqual([
196+
{ type: 'day', value: '29' },
197+
{ type: 'month', value: '02' },
198+
{ type: 'year', value: '2024' },
199+
]);
200+
expect(parts.map((part) => part.value).join('/')).toBe('29/02/2024');
201+
});
202+
203+
it('switches New York offset safely across the DST boundary', () => {
204+
const beforeBoundary = new Date('2024-03-10T06:59:59.000Z');
205+
const afterBoundary = new Date('2024-03-10T07:00:00.000Z');
206+
207+
const beforeParts = toVisualDateParts(beforeBoundary, 'America/New_York');
208+
const afterParts = toVisualDateParts(afterBoundary, 'America/New_York');
209+
const beforeOffset = getObservedOffsetMinutes(beforeBoundary, 'America/New_York');
210+
const afterOffset = getObservedOffsetMinutes(afterBoundary, 'America/New_York');
211+
212+
expect(beforeParts).toMatchObject({ hour: 1, minute: 59, second: 59 });
213+
expect(afterParts).toMatchObject({ hour: 3, minute: 0, second: 0 });
214+
expect(beforeOffset).toBe(-300);
215+
expect(afterOffset).toBe(-240);
216+
expect(isOffsetWithinLimit('America/New_York', beforeOffset)).toBe(true);
217+
expect(isOffsetWithinLimit('America/New_York', afterOffset)).toBe(true);
218+
});
219+
220+
it('falls back to UTC for missing or unmapped regional options', () => {
221+
const instant = new Date('2024-06-15T12:34:56.000Z');
222+
223+
expect(resolveTimezone(undefined)).toBe('UTC');
224+
expect(resolveTimezone('')).toBe('UTC');
225+
expect(resolveTimezone('Europe/Paris')).toBe('UTC');
226+
227+
expect(toVisualDateKey(instant, resolveTimezone(undefined))).toBe('2024-06-15');
228+
expect(toVisualDateKey(instant, resolveTimezone('Europe/Paris'))).toBe('2024-06-15');
229+
expect(getObservedOffsetMinutes(instant, resolveTimezone(''))).toBe(0);
230+
expect(isOffsetWithinLimit('UTC', 0)).toBe(true);
231+
});
232+
});

0 commit comments

Comments
 (0)