Skip to content

Commit fc78cc2

Browse files
committed
test(rest): pin the year-range sentence at POST /data/:object and an import row
Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 210556d commit fc78cc2

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20846] A `date` or `datetime` value refused for its YEAR is refused in
5+
* words that name the kind's years, at the public write doors — not with
6+
* "must be a valid datetime (ISO-8601)", which is false for a readable ISO 8601
7+
* value such as `0500-07-15T10:00:00Z` and sent its author to rewrite a
8+
* spelling when no spelling of that year is admitted.
9+
*
10+
* Two doors, over a real `SqlDriver` on SQLite:
11+
*
12+
* - `POST /api/v1/data/:object` — `400 VALIDATION_FAILED`, the field's code
13+
* `invalid_date` and its `constraint` `{ type }` exactly as before; only the
14+
* sentence moves;
15+
* - `POST /api/v1/data/:object/import` — each refused row carries the same
16+
* code and the same sentence, whichever reader refused it: the import's cell
17+
* reader for a cell with more than four year digits (`+010000-01-01`, no
18+
* shape it takes), the write door behind it for a cell it takes
19+
* (`0500-07-15T10:00:00Z`, `0000-06-15`).
20+
*
21+
* The CONTROL is a malformed value, which keeps its sentence at each door —
22+
* "must be a valid datetime (ISO-8601)" at the write door, the import's own
23+
* `"…" is not a valid datetime` for a cell — beside the years' edges, which
24+
* are written.
25+
*
26+
* Measured on the base (`bee75cebe`) through these doors: every refused
27+
* value below answered with its ISO sentence ("Opened must be a valid datetime
28+
* (ISO-8601)", "Placed must be a valid date (ISO-8601)"), and the import cell
29+
* `+010000-01-01` with `Placed: "+010000-01-01" is not a valid date`.
30+
*/
31+
32+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33+
import { ObjectQL } from '@objectstack/objectql';
34+
import { SqlDriver } from '@objectstack/driver-sql';
35+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
36+
import { RestServer } from './rest-server';
37+
38+
const OBJECT = 'rest_year_message_20846';
39+
40+
const LEDGER = {
41+
name: OBJECT,
42+
label: 'Ledger 20846',
43+
systemFields: false,
44+
fields: {
45+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
46+
placed_on: { name: 'placed_on', type: 'date' as const, label: 'Placed' },
47+
opened_at: { name: 'opened_at', type: 'datetime' as const, label: 'Opened' },
48+
},
49+
};
50+
51+
const DATE_RANGE = 'Placed must be a date in the years 0001 to 9999';
52+
const DATETIME_RANGE = 'Opened must be a datetime whose UTC year falls in the years 1000 to 9999';
53+
54+
/** field · written value · the refusal's sentence */
55+
const WRITE_REFUSED: ReadonlyArray<readonly [string, string, string]> = [
56+
['opened_at', '0500-07-15T10:00:00Z', DATETIME_RANGE],
57+
['opened_at', '0999-12-31T23:59:59.999Z', DATETIME_RANGE],
58+
['opened_at', '9999-12-31T23:59:59-01:00', DATETIME_RANGE],
59+
['opened_at', '+010000-01-01T00:00:00.000Z', DATETIME_RANGE],
60+
['placed_on', '+010000-01-01', DATE_RANGE],
61+
['placed_on', '+010000-01-01T00:00:00.000Z', DATE_RANGE],
62+
['placed_on', '0000-06-15', DATE_RANGE],
63+
// CONTROL: malformed — the ISO sentence stays.
64+
['opened_at', 'not-a-date', 'Opened must be a valid datetime (ISO-8601)'],
65+
['opened_at', '2026-02-30T10:00:00Z', 'Opened must be a valid datetime (ISO-8601)'],
66+
['placed_on', 'not-a-date', 'Placed must be a valid date (ISO-8601)'],
67+
];
68+
69+
/** One import row per cell: id · placed_on · opened_at · the row's sentence, or null when it is stored. */
70+
const IMPORT_ROWS: ReadonlyArray<readonly [string, string, string, string | null]> = [
71+
['r1', '', '0500-07-15T10:00:00Z', DATETIME_RANGE],
72+
['r2', '+010000-01-01', '', DATE_RANGE],
73+
['r3', '0000-06-15', '', DATE_RANGE],
74+
// CONTROL: malformed — the import's own sentence stays.
75+
['r4', '', 'not-a-date', 'Opened: "not-a-date" is not a valid datetime'],
76+
['r5', 'not-a-date', '', 'Placed: "not-a-date" is not a valid date'],
77+
// CONTROL: the years' edges are stored.
78+
['r6', '0001-01-01', '1000-01-01T00:00:00Z', null],
79+
['r7', '9999-12-31', '9999-12-31T23:59:59Z', null],
80+
];
81+
82+
function createMockServer() {
83+
const noop = () => {};
84+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
85+
}
86+
87+
function makeRes() {
88+
const res: any = {
89+
write: () => true, end: () => {},
90+
header: () => res,
91+
status: (code: number) => { res._status = code; return res; },
92+
json: (body: any) => { res._json = body; return res; },
93+
};
94+
return res;
95+
}
96+
97+
describe('[#20846] a date / datetime refused for its year names the years — POST /data/:object and an import row', () => {
98+
let engine: ObjectQL;
99+
let call: (method: string, path: string, body: unknown) => Promise<{ status: number; body: any }>;
100+
101+
beforeAll(async () => {
102+
engine = new ObjectQL();
103+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), true);
104+
await engine.init();
105+
engine.registry.registerObject(LEDGER as any);
106+
await engine.syncSchemas();
107+
const protocol = new ObjectStackProtocolImplementation(engine as any);
108+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
109+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
110+
rest.registerRoutes();
111+
call = async (method, path, body) => {
112+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
113+
expect(route, `${method} ${path}`).toBeDefined();
114+
const res = makeRes();
115+
await route!.handler({ params: { object: OBJECT }, body, query: {}, headers: {} } as any, res);
116+
return { status: res._status ?? 200, body: res._json };
117+
};
118+
});
119+
120+
afterAll(async () => {
121+
try { await engine?.destroy(); } catch { /* noop */ }
122+
});
123+
124+
it.each(WRITE_REFUSED)('POST %s = %j → 400 VALIDATION_FAILED, invalid_date, { type }, and the sentence', async (field, value, sentence) => {
125+
const res = await call('POST', '/api/v1/data/:object', { id: `w-${field}`, [field]: value });
126+
expect(res.status, JSON.stringify(res.body)).toBe(400);
127+
expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' });
128+
expect(res.body.fields).toHaveLength(1);
129+
const [finding] = res.body.fields;
130+
expect(finding).toMatchObject({ field, code: 'invalid_date' });
131+
expect(finding.constraint).toEqual({ type: LEDGER.fields[field as keyof typeof LEDGER.fields].type });
132+
expect(finding.message).toBe(sentence);
133+
// The envelope's human `error` — what a toast or the CLI shows — leads with the field's sentence.
134+
expect(String(res.body.error).split('; ')[0]).toBe(sentence);
135+
expect(await engine.findOne(OBJECT, { where: { id: `w-${field}` } })).toBeNull();
136+
});
137+
138+
it('an import row gets the same code and sentence, whichever reader refused it — and the edges are stored', async () => {
139+
const csv = ['ID,Placed,Opened', ...IMPORT_ROWS.map(([id, day, at]) => `${id},${day},${at}`)].join('\r\n');
140+
const res = await call('POST', '/api/v1/data/:object/import', {
141+
format: 'csv', csv, mapping: { ID: 'id', Placed: 'placed_on', Opened: 'opened_at' }, writeMode: 'insert',
142+
});
143+
expect(res.status ?? 200, JSON.stringify(res.body)).toBe(200);
144+
const refused = IMPORT_ROWS.filter((r) => r[3] !== null);
145+
expect(res.body).toMatchObject({ total: IMPORT_ROWS.length, ok: IMPORT_ROWS.length - refused.length, errors: refused.length });
146+
for (const [i, [id, day, , sentence]] of IMPORT_ROWS.entries()) {
147+
const row = res.body.results[i];
148+
if (sentence === null) {
149+
expect(row, id).toMatchObject({ ok: true });
150+
expect(await engine.findOne(OBJECT, { where: { id } }), id).not.toBeNull();
151+
continue;
152+
}
153+
expect(row, id).toMatchObject({ ok: false, action: 'failed', code: 'invalid_date', field: day ? 'placed_on' : 'opened_at' });
154+
expect(row.error, id).toBe(sentence);
155+
expect(await engine.findOne(OBJECT, { where: { id } }), id).toBeNull();
156+
}
157+
});
158+
});

0 commit comments

Comments
 (0)