Skip to content

Commit 9b31e7b

Browse files
hotlongclaude
andcommitted
test(rest): pin /import date cells to ISO 8601 or the export shape on a real day, under two host zones (#20534)
Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 124be20 commit 9b31e7b

4 files changed

Lines changed: 369 additions & 7 deletions

File tree

‎packages/rest/src/import-business-timezone.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,18 @@ describe('parseDateCell — an offset-free datetime cell reads in the business t
109109
expect(parseDateCell('2026-01-15 12:00:00', 'datetime', NEW_YORK)).toBe('2026-01-15T17:00:00.000Z');
110110
});
111111

112-
it('accepts the shapes a spreadsheet actually writes (T or space, optional seconds/millis, slashes)', () => {
112+
it('accepts the ISO and export shapes (T or space, optional seconds/millis)', () => {
113113
process.env.TZ = 'America/Los_Angeles';
114114
expect(parseDateCell('2026-08-01T06:00:00', 'datetime', SHANGHAI)).toBe(CROSS_MONTH_UTC);
115115
expect(parseDateCell('2026-08-01 06:00', 'datetime', SHANGHAI)).toBe(CROSS_MONTH_UTC);
116-
expect(parseDateCell('2026/08/01 06:00:00', 'datetime', SHANGHAI)).toBe(CROSS_MONTH_UTC);
117116
expect(parseDateCell('2026-08-01 06:00:00.123', 'datetime', SHANGHAI)).toBe('2026-07-31T22:00:00.123Z');
118117
});
119118

120119
it('an unparseable cell is still a coercion failure, not an invalid instant', () => {
121120
expect(parseDateCell('not a date', 'datetime', SHANGHAI)).toBeUndefined();
122121
expect(parseDateCell('2026-13-45 99:00:00', 'datetime', SHANGHAI)).toBeUndefined();
122+
// [#20534] Neither ISO 8601 nor the export shape: refused, not read.
123+
expect(parseDateCell('2026/08/01 06:00:00', 'datetime', SHANGHAI)).toBeUndefined();
123124
});
124125
});
125126

‎packages/rest/src/import-coerce.test.ts‎

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* `coerceRow` driven by a fake reference resolver.
77
*/
88

9-
import { describe, it, expect } from 'vitest';
9+
import { describe, it, expect, afterEach } from 'vitest';
1010
import {
1111
parseBooleanCell,
1212
parseNumberCell,
@@ -84,7 +84,6 @@ describe('parseNumberCell', () => {
8484
describe('parseDateCell', () => {
8585
it('normalises bare calendar dates without timezone drift', () => {
8686
expect(parseDateCell('2026-06-30', 'date')).toBe('2026-06-30');
87-
expect(parseDateCell('2026/6/3', 'date')).toBe('2026-06-03');
8887
});
8988
it('emits full ISO for datetime', () => {
9089
expect(parseDateCell('2026-06-30', 'datetime')).toBe('2026-06-30T00:00:00.000Z');
@@ -99,6 +98,102 @@ describe('parseDateCell', () => {
9998
});
10099
});
101100

101+
/**
102+
* [#20534] A text cell is read only in ISO 8601 or the export's own
103+
* `YYYY-MM-DD HH:mm:ss`, on a calendar day that exists; everything else is
104+
* refused (`undefined`, so the row's `invalid_date`), never rolled over, never
105+
* read in the host's zone and never read month-first. A `date`'s year keeps
106+
* four digits. Every case runs under two host zones that disagree by twelve
107+
* hours, and must answer the same under both — the host-zone reading this
108+
* removes answered differently (`07/15/2026 10:00` was `…T14:00Z` in New York
109+
* and `…T02:00Z` in Shanghai).
110+
*/
111+
describe('[#20534] parseDateCell — ISO 8601 or the export shape, on a real day', () => {
112+
const HOST_ZONES = ['America/New_York', 'Asia/Shanghai'];
113+
const originalTz = process.env.TZ;
114+
afterEach(() => {
115+
if (originalTz === undefined) delete process.env.TZ;
116+
else process.env.TZ = originalTz;
117+
});
118+
119+
/** Run `fn` under each host zone; the answers must agree. */
120+
function onBothHosts(fn: () => string | undefined): string | undefined {
121+
const answers = HOST_ZONES.map((tz) => {
122+
process.env.TZ = tz;
123+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(tz);
124+
return fn();
125+
});
126+
expect(answers[1]).toBe(answers[0]);
127+
return answers[0];
128+
}
129+
130+
type Kind = 'date' | 'datetime' | 'time';
131+
132+
const REFUSED: ReadonlyArray<readonly [cell: unknown, kind: Kind]> = [
133+
// An impossible day, in every shape and on every branch — was rolled over.
134+
['2026-02-30', 'date'], ['2026-02-30', 'datetime'], ['2026-02-30', 'time'],
135+
['2026-02-29', 'datetime'], ['2026-04-31', 'datetime'],
136+
['2026-02-30 10:00', 'datetime'], ['2026-02-30 10:00', 'date'], ['2026-02-30 10:00', 'time'],
137+
['2026-02-30T10:00:00Z', 'datetime'], ['2026-02-30T10:00:00Z', 'date'], ['2026-02-30T10:00:00Z', 'time'],
138+
// Locale and prose spellings — were read in the host zone, and month-first.
139+
['07/15/2026 10:00', 'datetime'], ['07/15/2026 10:00', 'date'], ['07/15/2026 10:00', 'time'],
140+
['07/08/2026', 'datetime'], ['07/08/2026', 'date'],
141+
['07/15/2026', 'date'], ['15 July 2026', 'date'], ['15 July 2026', 'datetime'],
142+
['Jul 15 2026 10:00', 'time'], ['Wed, 15 Jul 2026 10:00:00 GMT', 'datetime'],
143+
// Year-first but not ISO 8601.
144+
['2026/6/3', 'date'], ['2026/07/15', 'date'], ['2026-7-15', 'date'],
145+
['2026/08/01 06:00:00', 'datetime'], ['2026-07-15 9:00', 'datetime'],
146+
// Reduced / expanded forms, a zone after a space, lower-case `t` / `z`.
147+
['2026', 'date'], ['2026-07', 'datetime'], ['+002026-07-15', 'date'],
148+
['2026-07-15 10:00Z', 'datetime'], ['2026-07-15 10:00:00+08:00', 'datetime'],
149+
['2026-07-15t10:00:00z', 'datetime'],
150+
// A zone-naive 24:00 — was handed to `new Date(s)`, in the host zone.
151+
['2026-07-15 24:00', 'datetime'], ['2026-07-15T24:00:00', 'date'],
152+
// A number — `new Date(String(n))` read it as a year, in the host zone.
153+
[2026, 'date'], [45000, 'datetime'], [45000, 'time'],
154+
];
155+
156+
it.each(REFUSED)('refuses %j as a %s cell on every host', (cell, kind) => {
157+
expect(onBothHosts(() => parseDateCell(cell, kind))).toBeUndefined();
158+
});
159+
160+
const ADMITTED: ReadonlyArray<readonly [cell: unknown, kind: Kind, stored: string]> = [
161+
// ISO 8601 and the export shape keep their readings.
162+
['2026-07-15', 'date', '2026-07-15'],
163+
['2026-07-15', 'datetime', '2026-07-15T00:00:00.000Z'],
164+
['2028-02-29', 'date', '2028-02-29'],
165+
['2028-02-29T10:00:00Z', 'datetime', '2028-02-29T10:00:00.000Z'],
166+
['2026-07-15 10:00:00', 'datetime', '2026-07-15T10:00:00.000Z'],
167+
['2026-07-15 10:00:00', 'date', '2026-07-15'],
168+
['2026-07-15 10:00:00', 'time', '10:00:00'],
169+
['2026-07-15T10:00', 'datetime', '2026-07-15T10:00:00.000Z'],
170+
['2026-07-15 10:00:00.123', 'datetime', '2026-07-15T10:00:00.123Z'],
171+
['2026-07-15T10:00:00Z', 'datetime', '2026-07-15T10:00:00.000Z'],
172+
['2026-07-15T10:00:00+08:00', 'datetime', '2026-07-15T02:00:00.000Z'],
173+
['2026-07-15T10:00:00+0800', 'datetime', '2026-07-15T02:00:00.000Z'],
174+
['2026-07-15T02:00:00+08:00', 'date', '2026-07-14'],
175+
['2026-07-15T10:00:00+08:00', 'time', '02:00:00'],
176+
['2026-07-15T24:00:00Z', 'datetime', '2026-07-16T00:00:00.000Z'],
177+
[' 2026-07-15 ', 'date', '2026-07-15'],
178+
['10:00', 'time', '10:00:00'],
179+
// The year keeps four digits on every `date` branch.
180+
['0500-01-01', 'date', '0500-01-01'],
181+
['0001-01-01', 'date', '0001-01-01'],
182+
['0999-12-31 10:00:00', 'date', '0999-12-31'],
183+
['0050-01-01T10:00:00Z', 'date', '0050-01-01'],
184+
[new Date('0500-01-01T00:00:00Z'), 'date', '0500-01-01'],
185+
// A bare day into a `datetime` is spelled from the day, never `Date.UTC(y, …)`,
186+
// which read years 0..99 as 1900..1999 (`0001-01-01` was stored as 1901).
187+
['0001-01-01', 'datetime', '0001-01-01T00:00:00.000Z'],
188+
['0050-01-01', 'datetime', '0050-01-01T00:00:00.000Z'],
189+
['0500-01-01', 'datetime', '0500-01-01T00:00:00.000Z'],
190+
];
191+
192+
it.each(ADMITTED)('reads %j as a %s cell as %j on every host', (cell, kind, stored) => {
193+
expect(onBothHosts(() => parseDateCell(cell, kind))).toBe(stored);
194+
});
195+
});
196+
102197
describe('matchOption', () => {
103198
const options = [{ label: '高', value: 'high' }, { label: '低', value: 'low' }];
104199
it('matches by option value (code)', () => {
@@ -139,7 +234,7 @@ describe('coerceRow', () => {
139234
note: { type: 'text' },
140235
});
141236
const { data, errors } = await coerceRow(
142-
{ done: '是', amount: '$1,200.50', priority: '高', tags: 'A, B', due: '2026/07/01', note: ' hi ' },
237+
{ done: '是', amount: '$1,200.50', priority: '高', tags: 'A, B', due: '2026-07-01', note: ' hi ' },
143238
metaMap,
144239
{},
145240
);

0 commit comments

Comments
 (0)