Skip to content

Commit 279ca42

Browse files
hotlongclaude
andcommitted
test(rest): year-first dates are admission cases again; pin their edges and the real /import door (#20534)
Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46f8026 commit 279ca42

4 files changed

Lines changed: 54 additions & 16 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,17 @@ 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 ISO and export shapes (T or space, optional seconds/millis)', () => {
112+
it('accepts the shapes a spreadsheet actually writes (T or space, optional seconds/millis, slashes)', () => {
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);
116117
expect(parseDateCell('2026-08-01 06:00:00.123', 'datetime', SHANGHAI)).toBe('2026-07-31T22:00:00.123Z');
117118
});
118119

119120
it('an unparseable cell is still a coercion failure, not an invalid instant', () => {
120121
expect(parseDateCell('not a date', 'datetime', SHANGHAI)).toBeUndefined();
121122
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();
124123
});
125124
});
126125

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ 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');
8788
});
8889
it('emits full ISO for datetime', () => {
8990
expect(parseDateCell('2026-06-30', 'datetime')).toBe('2026-06-30T00:00:00.000Z');
@@ -99,16 +100,17 @@ describe('parseDateCell', () => {
99100
});
100101

101102
/**
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
103+
* [#20534] A text cell is read only in ISO 8601, the export's own
104+
* `YYYY-MM-DD HH:mm:ss` or a year-first date (`2026/7/15`, `2026/7/15 9:00`,
105+
* by the maintainer ruling on the card), on a calendar day that exists;
106+
* everything else is refused (`undefined`, so the row's `invalid_date`), never rolled over, never
105107
* read in the host's zone and never read month-first. A `date`'s year keeps
106108
* four digits. Every case runs under two host zones that disagree by twelve
107109
* hours, and must answer the same under both — the host-zone reading this
108110
* removes answered differently (`07/15/2026 10:00` was `…T14:00Z` in New York
109111
* and `…T02:00Z` in Shanghai).
110112
*/
111-
describe('[#20534] parseDateCell — ISO 8601 or the export shape, on a real day', () => {
113+
describe('[#20534] parseDateCell — ISO 8601, the export shape or a year-first date, on a real day', () => {
112114
const HOST_ZONES = ['America/New_York', 'Asia/Shanghai'];
113115
const originalTz = process.env.TZ;
114116
afterEach(() => {
@@ -140,9 +142,12 @@ describe('[#20534] parseDateCell — ISO 8601 or the export shape, on a real day
140142
['07/08/2026', 'datetime'], ['07/08/2026', 'date'],
141143
['07/15/2026', 'date'], ['15 July 2026', 'date'], ['15 July 2026', 'datetime'],
142144
['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'],
145+
// Year-first, outside its one form: an impossible day, a mixed separator,
146+
// a clock out of range, a `T`, a zone, a fraction, a two-digit year.
147+
['2026/2/30', 'date'], ['2026/2/30', 'datetime'], ['2026/7-15', 'date'],
148+
['2026/7/15 24:00', 'datetime'], ['2026/7/15 9:60', 'datetime'],
149+
['2026/7/15T9:00', 'datetime'], ['2026/7/15 9:00Z', 'datetime'],
150+
['2026/7/15 9:00:00.5', 'datetime'], ['26/7/15', 'date'], ['07/15/2026', 'datetime'],
146151
// Reduced / expanded forms, a zone after a space, lower-case `t` / `z`.
147152
['2026', 'date'], ['2026-07', 'datetime'], ['+002026-07-15', 'date'],
148153
['2026-07-15 10:00Z', 'datetime'], ['2026-07-15 10:00:00+08:00', 'datetime'],
@@ -176,6 +181,19 @@ describe('[#20534] parseDateCell — ISO 8601 or the export shape, on a real day
176181
['2026-07-15T24:00:00Z', 'datetime', '2026-07-16T00:00:00.000Z'],
177182
[' 2026-07-15 ', 'date', '2026-07-15'],
178183
['10:00', 'time', '10:00:00'],
184+
// A year-first date (Excel's zh-CN / ja-JP short date): the padded ISO day,
185+
// and a clock read as a wall clock exactly as the export shape's is.
186+
['2026/6/3', 'date', '2026-06-03'],
187+
['2026/07/15', 'date', '2026-07-15'],
188+
['2026-7-15', 'date', '2026-07-15'],
189+
['2026/7/15', 'datetime', '2026-07-15T00:00:00.000Z'],
190+
['2026/7/15 9:00', 'datetime', '2026-07-15T09:00:00.000Z'],
191+
['2026/7/15 9:00', 'date', '2026-07-15'],
192+
['2026/7/15 9:00', 'time', '09:00:00'],
193+
['2026/08/01 06:00:00', 'datetime', '2026-08-01T06:00:00.000Z'],
194+
['2026-07-15 9:00', 'datetime', '2026-07-15T09:00:00.000Z'],
195+
['2028/2/29', 'date', '2028-02-29'],
196+
['0500/1/1', 'date', '0500-01-01'],
179197
// The year keeps four digits on every `date` branch.
180198
['0500-01-01', 'date', '0500-01-01'],
181199
['0001-01-01', 'date', '0001-01-01'],
@@ -234,7 +252,7 @@ describe('coerceRow', () => {
234252
note: { type: 'text' },
235253
});
236254
const { data, errors } = await coerceRow(
237-
{ done: '是', amount: '$1,200.50', priority: '高', tags: 'A, B', due: '2026-07-01', note: ' hi ' },
255+
{ done: '是', amount: '$1,200.50', priority: '高', tags: 'A, B', due: '2026/07/01', note: ' hi ' },
238256
metaMap,
239257
{},
240258
);

‎packages/rest/src/import-date-cell-iso-real-day.test.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
/**
44
* [#20534] `POST /api/v1/data/:object/import` reads a `date`, `datetime` or
5-
* `time` text cell only in ISO 8601 or the platform's own export shape
6-
* (`YYYY-MM-DD HH:mm:ss`), on a calendar day that exists, and pads a `date`'s
7-
* year to four digits — through the real route over a real `SqlDriver`
5+
* `time` text cell only in ISO 8601, the platform's own export shape
6+
* (`YYYY-MM-DD HH:mm:ss`) or a year-first date (`2026/7/15`, `2026/7/15 9:00`,
7+
* by the maintainer ruling on the card), on a calendar day that exists, and
8+
* pads a `date`'s year to four digits — through the real route over a real `SqlDriver`
89
* (better-sqlite3 `:memory:`), under two host zones twelve hours apart.
910
*
1011
* Measured through this route, JSON rows, `writeMode: 'insert'`, no business
@@ -24,6 +25,9 @@
2425
* | `2026-07-15 24:00` | datetime | `2026-07-16T04:00Z` · `2026-07-15T16:00Z` | row refused |
2526
* | `0500-01-01` / `0001-01-01` | date | row refused (`500-01-01` reached the write door) | `0500-01-01` / `0001-01-01` |
2627
* | `0001-01-01` | datetime | `1901-01-01T00:00:00.000Z` | `0001-01-01T00:00:00.000Z` |
28+
* | `2026/7/15` | date | `2026-07-15` | unchanged |
29+
* | `2026/7/15 9:00` | datetime | `2026-07-15T09:00:00.000Z` | unchanged |
30+
* | `2026/2/30` | date | refused (`2026-02-30` reached the write door) | row refused by the reader |
2731
*
2832
* The `InMemoryDriver` column is this file's by construction, not by a second
2933
* arm: every cell is judged by the import's own reader (`parseDateCell`) before
@@ -69,6 +73,7 @@ const REFUSED: ReadonlyArray<readonly [field: Field, cell: string]> = [
6973
['d', '15 July 2026'],
7074
['t', '07/15/2026 10:00'],
7175
['dt', '2026-07-15 24:00'],
76+
['d', '2026/2/30'],
7277
];
7378

7479
/** Admitted cells and what they store — the padding, the ISO control and the export shape. */
@@ -81,6 +86,9 @@ const ADMITTED: ReadonlyArray<readonly [field: Field, cell: string, stored: stri
8186
['dt', '2026-07-15T10:00:00+08:00', '2026-07-15T02:00:00.000Z'],
8287
['dt', '2026-07-15 10:00:00', '2026-07-15T10:00:00.000Z'],
8388
['t', '10:00', '10:00:00'],
89+
// A year-first date (Excel's zh-CN / ja-JP short date) stays admitted, padded.
90+
['d', '2026/7/15', '2026-07-15'],
91+
['dt', '2026/7/15 9:00', '2026-07-15T09:00:00.000Z'],
8492
];
8593

8694
function makeSqliteDriver() {
@@ -209,6 +217,17 @@ describe.each(HOST_ZONES)('[#20534] /import date cells, host TZ=$tz', ({ tz, jul
209217
}
210218
});
211219

220+
it('stores a year-first date-time as the same instant the export shape stores', async () => {
221+
const res = await ctx.importRows({
222+
format: 'json', writeMode: 'insert',
223+
rows: [{ id: 'yf', dt: '2026/7/15 9:00' }, { id: 'ex', dt: '2026-07-15 09:00:00' }],
224+
});
225+
expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 });
226+
const yearFirst = await stored(ctx.engine, 'yf', 'dt');
227+
expect(yearFirst).toBe(await stored(ctx.engine, 'ex', 'dt'));
228+
expect(yearFirst).toBe('2026-07-15T09:00:00.000Z');
229+
});
230+
212231
it('round-trips the export shape: an exported row re-imports as the same day and instant', async () => {
213232
await ctx.engine.insert(OBJECT, { id: 'src', d: '2026-07-15', dt: '2026-07-15T10:00:00.000Z', t: '10:00:00' });
214233
const exported = await ctx.exportCsv();

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ describe('import route — real engine + protocol integration', () => {
190190
it('coerces every special value type on insert (是→true, 高→high, name→id, date→ISO)', async () => {
191191
const csv = [
192192
'ID,标题,完成,优先级,分数,截止,负责人',
193-
'1,写代码,是,高,"1,200",2026-06-30,张三',
193+
'1,写代码,是,高,"1,200",2026/06/30,张三',
194194
].join('\n');
195195
const res = await call(route, {
196196
format: 'csv', csv,
@@ -347,7 +347,7 @@ describe('import route — real engine + protocol integration', () => {
347347
const ws = wb.addWorksheet('Sheet1');
348348
ws.addRow(['ID', '标题', '完成', '优先级', '分数', '截止', '负责人']);
349349
ws.addRow(['1', '写代码', '是', '高', 1200, new Date('2026-06-30T00:00:00Z'), '张三']);
350-
ws.addRow(['2', '测试', '否', '低', 3, '2026-07-01', '李四']);
350+
ws.addRow(['2', '测试', '否', '低', 3, '2026/07/01', '李四']);
351351
const buf = await wb.xlsx.writeBuffer();
352352
const xlsxBase64 = Buffer.from(buf).toString('base64');
353353

@@ -361,6 +361,8 @@ describe('import route — real engine + protocol integration', () => {
361361
expect(String(one.due)).toContain('2026-06-30');
362362
const two = await engine.findOne('task', { where: { id: '2' } });
363363
expect(two).toMatchObject({ title: '测试', done: false, priority: 'low', score: 3, owner: 'u2' });
364+
// [#20534] A year-first text cell (Excel's zh-CN short date) is read as its ISO day.
365+
expect(two.due).toBe('2026-07-01');
364366
});
365367

366368
it('reads xlsxBase64 without an explicit format and honors the sheet selector', async () => {

0 commit comments

Comments
 (0)