|
| 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