|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#13994] The import-job DTO serves CANONICAL ISO-8601 for its four timestamp |
| 5 | + * fields, on every dialect and under every process timezone. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * `importJobToProgress` rendered all four stamps through `String(v)`. On |
| 10 | + * Postgres and MySQL — the production default driver — those columns arrive as |
| 11 | + * JS `Date`s, so `String` ran `Date.prototype.toString` and the REST contract |
| 12 | + * served |
| 13 | + * |
| 14 | + * "Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)" |
| 15 | + * |
| 16 | + * where `ImportJobProgressSchema` promises `"2026-08-30T10:19:25.947Z"`: |
| 17 | + * milliseconds dropped, the SERVER's timezone baked in, no `Z`, and not |
| 18 | + * `Date.parse`-safe for a client doing strict ISO parsing. |
| 19 | + * |
| 20 | + * Why all four, and why nothing upstream repaired them: `formatOutput`'s two |
| 21 | + * timestamp repairs — the `AUDIT_TIMESTAMP_COLUMNS` pass (`created_at`) and the |
| 22 | + * `normalizeSqliteDatetimeOutput` pass over `datetimeFields` |
| 23 | + * (`started_at` / `completed_at` / `reverted_at`, all declared `Field.datetime` |
| 24 | + * on `sys_import_job`) — both sit INSIDE `formatOutput`'s `if (this.isSqlite)` |
| 25 | + * arm. ⚠️ A declared `Field.datetime` is NOT protected on Postgres/MySQL. |
| 26 | + * |
| 27 | + * ## Why the obvious pin would have proved nothing |
| 28 | + * |
| 29 | + * SQLite stores and returns canonical ISO text, so `String()` was an IDENTITY |
| 30 | + * there and every SQLite-backed test — including this package's real-engine |
| 31 | + * `import-job-integration.test.ts` — stayed green through the whole life of the |
| 32 | + * defect. A fixture of ISO strings cannot fail. **So these cases drive real |
| 33 | + * `Date`s through the real routes**, which is the shape only a non-SQLite |
| 34 | + * driver produces, and they do it under a forced non-UTC process zone. |
| 35 | + * |
| 36 | + * ## What is pinned — the property, not the spelling |
| 37 | + * |
| 38 | + * Not "the mapper calls `toISOString()`". The invariants are: |
| 39 | + * |
| 40 | + * 1. **A `Date` from the read door is served as canonical ISO-Z**, on all |
| 41 | + * four fields, through both mappers (progress and summary), with the four |
| 42 | + * stamps DISTINCT so no field can pass by echoing another's value. |
| 43 | + * 2. **The answer does not depend on `process.env.TZ`** — swept over three |
| 44 | + * zones, with a non-vacuity control proving those zones really do move the |
| 45 | + * broken spelling (three green rows under three identical spellings would |
| 46 | + * prove nothing about timezone independence). |
| 47 | + * 3. **An already-canonical string is a fixed point** (the SQLite shape is |
| 48 | + * returned byte-identical). This is what shows the pin DISCRIMINATES |
| 49 | + * rather than being globally sensitive to any change at the seam. |
| 50 | + * 4. **The response satisfies the declared contract**, asserted by a full |
| 51 | + * `safeParse` against the spec's own `ImportJobProgressSchema` / |
| 52 | + * `ImportJobSummarySchema` — the judgement here is about a VALUE, so a |
| 53 | + * green parse is the assertion, not merely the absence of unknown keys. |
| 54 | + * This limb is also what refuses the tempting "just delete the `String()` |
| 55 | + * and let `JSON.stringify` do it" route: that emits the right text but |
| 56 | + * widens the declared `z.string()` to `string | Date`. |
| 57 | + * |
| 58 | + * ## What is deliberately NOT claimed here |
| 59 | + * |
| 60 | + * That `driver-sql` hands this seam a `Date` on Postgres. That is a fact about |
| 61 | + * `driver-sql`, measured beside the fix (`formatOutput`'s `isSqlite` bracketing) |
| 62 | + * and pinned in that package; `@objectstack/rest` must not grow a Postgres |
| 63 | + * dependency to restate it. What these tests own is the mapper's behaviour |
| 64 | + * GIVEN each input shape a driver can produce. |
| 65 | + */ |
| 66 | + |
| 67 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 68 | +// The contract itself, not a local restatement of it: the same schemas |
| 69 | +// `ImportJobApiContracts` names as the `output` of these very routes. |
| 70 | +import { ImportJobProgressSchema, ImportJobSummarySchema } from '@objectstack/spec/api'; |
| 71 | +import { RestServer } from './rest-server'; |
| 72 | + |
| 73 | +/** Canonical ISO-8601 UTC with milliseconds — what the contract promises. */ |
| 74 | +const CANONICAL_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; |
| 75 | + |
| 76 | +/** |
| 77 | + * Four DISTINCT instants, each with a distinct NON-ZERO millisecond component. |
| 78 | + * Distinct so no field can pass by echoing another's value; non-zero |
| 79 | + * milliseconds so the millisecond-dropping spelling cannot pass by accident. |
| 80 | + */ |
| 81 | +const CREATED = '2026-08-30T10:19:25.947Z'; |
| 82 | +const STARTED = '2026-08-30T10:20:31.001Z'; |
| 83 | +const COMPLETED = '2026-08-30T10:21:44.512Z'; |
| 84 | +const REVERTED = '2026-08-30T10:22:59.083Z'; |
| 85 | + |
| 86 | +const ALL_FOUR = { createdAt: CREATED, startedAt: STARTED, completedAt: COMPLETED, revertedAt: REVERTED }; |
| 87 | + |
| 88 | +/** The card's zone, a zone on the other side of UTC, and UTC itself. */ |
| 89 | +const ZONES = ['Asia/Shanghai', 'America/New_York', 'UTC'] as const; |
| 90 | + |
| 91 | +/** |
| 92 | + * One `sys_import_job` row as a driver materialises it. `stamp` decides the |
| 93 | + * shape of the four timestamp columns: `Date` (Postgres / MySQL / MongoDB) or |
| 94 | + * canonical ISO text (SQLite and friends). |
| 95 | + */ |
| 96 | +function makeRow(stamp: (iso: string) => unknown) { |
| 97 | + return { |
| 98 | + id: 'imp_13994', |
| 99 | + object_name: 'task', |
| 100 | + status: 'succeeded', |
| 101 | + dry_run: false, |
| 102 | + write_mode: 'insert', |
| 103 | + total_rows: 3, |
| 104 | + processed_rows: 3, |
| 105 | + created_count: 2, |
| 106 | + updated_count: 0, |
| 107 | + skipped_count: 0, |
| 108 | + error_count: 1, |
| 109 | + created_at: stamp(CREATED), |
| 110 | + started_at: stamp(STARTED), |
| 111 | + completed_at: stamp(COMPLETED), |
| 112 | + reverted_at: stamp(REVERTED), |
| 113 | + }; |
| 114 | +} |
| 115 | + |
| 116 | +function createMockServer() { |
| 117 | + const noop = () => {}; |
| 118 | + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; |
| 119 | +} |
| 120 | + |
| 121 | +function makeRes() { |
| 122 | + const res: any = { |
| 123 | + write: () => true, end: () => {}, |
| 124 | + header: () => res, |
| 125 | + status: (code: number) => { res._status = code; return res; }, |
| 126 | + json: (body: any) => { res._json = body; return res; }, |
| 127 | + }; |
| 128 | + return res; |
| 129 | +} |
| 130 | + |
| 131 | +/** |
| 132 | + * The REAL routes, over a protocol whose read door returns exactly `row`. |
| 133 | + * |
| 134 | + * A stub read door rather than a real engine ON PURPOSE: the shape under test |
| 135 | + * is the one a SQLite-backed engine cannot produce, and it is precisely the |
| 136 | + * unreachability of that shape from SQLite that hid this defect. |
| 137 | + */ |
| 138 | +function boot(row: unknown) { |
| 139 | + const protocol = { findData: async () => ({ records: [row] }) }; |
| 140 | + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); |
| 141 | + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); |
| 142 | + rest.registerRoutes(); |
| 143 | + const routes = rest.getRoutes(); |
| 144 | + const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path); |
| 145 | + return { |
| 146 | + progress: find('GET', '/api/v1/data/import/jobs/:jobId'), |
| 147 | + results: find('GET', '/api/v1/data/import/jobs/:jobId/results'), |
| 148 | + list: find('GET', '/api/v1/data/import/jobs'), |
| 149 | + }; |
| 150 | +} |
| 151 | + |
| 152 | +async function call(route: any, req: any = {}) { |
| 153 | + const res = makeRes(); |
| 154 | + await route.handler({ params: { jobId: 'imp_13994' }, query: {}, ...req } as any, res); |
| 155 | + return res._json; |
| 156 | +} |
| 157 | + |
| 158 | +const ORIGINAL_TZ = process.env.TZ; |
| 159 | +afterEach(() => { |
| 160 | + if (ORIGINAL_TZ === undefined) delete process.env.TZ; |
| 161 | + else process.env.TZ = ORIGINAL_TZ; |
| 162 | +}); |
| 163 | + |
| 164 | +describe('[#13994] the import-job DTO serves canonical ISO-8601 for a `Date` from the read door', () => { |
| 165 | + it('renders all four stamps canonically under a forced non-UTC process zone', async () => { |
| 166 | + process.env.TZ = 'Asia/Shanghai'; |
| 167 | + |
| 168 | + // NON-VACUITY CONTROL. The defect's spelling, evaluated right here under |
| 169 | + // the same zone: if `String(Date)` already produced canonical ISO, the |
| 170 | + // assertions below would be green against the broken code too. |
| 171 | + const broken = String(new Date(CREATED)); |
| 172 | + expect(broken, 'the broken spelling did not move — this pin would be vacuous').not.toBe(CREATED); |
| 173 | + expect(broken).not.toMatch(CANONICAL_ISO); |
| 174 | + |
| 175 | + const body = await call(boot(makeRow((iso) => new Date(iso))).progress); |
| 176 | + |
| 177 | + // All four, each against ITS OWN instant — distinct values, so a mapper |
| 178 | + // that echoed one stamp into all four fields fails here. |
| 179 | + expect(body).toMatchObject(ALL_FOUR); |
| 180 | + for (const [field, value] of Object.entries(ALL_FOUR)) { |
| 181 | + expect(body[field], `${field} is not canonical ISO-Z`).toMatch(CANONICAL_ISO); |
| 182 | + // Strict-ISO round-trip: what a client doing `Date.parse` receives. |
| 183 | + expect(new Date(body[field]).toISOString()).toBe(value); |
| 184 | + } |
| 185 | + |
| 186 | + // Limb 4: the declared contract, parsed by the spec's own schema. A bare |
| 187 | + // `Date` here (the "just delete the String()" route) fails this. |
| 188 | + const parsed = ImportJobProgressSchema.safeParse(body); |
| 189 | + expect(parsed.success, JSON.stringify((parsed as any).error?.issues)).toBe(true); |
| 190 | + }); |
| 191 | + |
| 192 | + it('gives the same answer under every process timezone, and the zones really do move the broken spelling', async () => { |
| 193 | + const served = new Set<string>(); |
| 194 | + const brokenSpellings = new Set<string>(); |
| 195 | + |
| 196 | + for (const zone of ZONES) { |
| 197 | + process.env.TZ = zone; |
| 198 | + brokenSpellings.add(String(new Date(CREATED))); |
| 199 | + const body = await call(boot(makeRow((iso) => new Date(iso))).progress); |
| 200 | + served.add(JSON.stringify([body.createdAt, body.startedAt, body.completedAt, body.revertedAt])); |
| 201 | + } |
| 202 | + |
| 203 | + // The control: three zones, three DIFFERENT broken spellings. Without |
| 204 | + // this, three green rows would say nothing about timezone independence. |
| 205 | + expect( |
| 206 | + brokenSpellings.size, |
| 207 | + 'the process zone did not move `String(Date)` — the sweep is vacuous', |
| 208 | + ).toBe(ZONES.length); |
| 209 | + |
| 210 | + // The property: one answer, whatever the server's zone. |
| 211 | + expect(served).toEqual(new Set([JSON.stringify([CREATED, STARTED, COMPLETED, REVERTED])])); |
| 212 | + }); |
| 213 | + |
| 214 | + it('serves the summary (list) DTO canonically too', async () => { |
| 215 | + process.env.TZ = 'Asia/Shanghai'; |
| 216 | + const body = await call(boot(makeRow((iso) => new Date(iso))).list); |
| 217 | + const [job] = body.jobs; |
| 218 | + |
| 219 | + // `importJobToSummary` re-reads `importJobToProgress`'s output, so this |
| 220 | + // is the second mapper's face on the same repair. |
| 221 | + expect(job).toMatchObject({ createdAt: CREATED, completedAt: COMPLETED, revertedAt: REVERTED }); |
| 222 | + for (const field of ['createdAt', 'completedAt', 'revertedAt'] as const) { |
| 223 | + expect(job[field], `${field} is not canonical ISO-Z`).toMatch(CANONICAL_ISO); |
| 224 | + } |
| 225 | + const parsed = ImportJobSummarySchema.safeParse(job); |
| 226 | + expect(parsed.success, JSON.stringify((parsed as any).error?.issues)).toBe(true); |
| 227 | + }); |
| 228 | + |
| 229 | + it('serves the results DTO canonically too', async () => { |
| 230 | + process.env.TZ = 'Asia/Shanghai'; |
| 231 | + const body = await call(boot(makeRow((iso) => new Date(iso))).results); |
| 232 | + expect(body).toMatchObject(ALL_FOUR); |
| 233 | + }); |
| 234 | +}); |
| 235 | + |
| 236 | +describe('[#13994] an already-canonical string is a fixed point — the pin discriminates', () => { |
| 237 | + it('returns the SQLite shape byte-identical, under a non-UTC zone', async () => { |
| 238 | + process.env.TZ = 'Asia/Shanghai'; |
| 239 | + const body = await call(boot(makeRow((iso) => iso)).progress); |
| 240 | + |
| 241 | + // Idempotence: the dialect that was already correct must not move. A |
| 242 | + // repair that re-derived every value (`new Date(v).toISOString()`) would |
| 243 | + // pass the `Date` cases above and still be a change in behaviour here. |
| 244 | + expect(body).toMatchObject(ALL_FOUR); |
| 245 | + expect(ImportJobProgressSchema.safeParse(body).success).toBe(true); |
| 246 | + }); |
| 247 | + |
| 248 | + it('leaves a missing optional stamp absent, and an absent `created_at` an empty string', async () => { |
| 249 | + process.env.TZ = 'Asia/Shanghai'; |
| 250 | + // The presence-guards are semantics this repair does NOT touch: a job |
| 251 | + // that has not started yet omits the three optional stamps entirely. |
| 252 | + const row: any = makeRow((iso) => new Date(iso)); |
| 253 | + delete row.started_at; delete row.completed_at; delete row.reverted_at; delete row.created_at; |
| 254 | + |
| 255 | + const body = await call(boot(row).progress); |
| 256 | + expect('startedAt' in body).toBe(false); |
| 257 | + expect('completedAt' in body).toBe(false); |
| 258 | + expect('revertedAt' in body).toBe(false); |
| 259 | + expect(body.createdAt).toBe(''); |
| 260 | + }); |
| 261 | +}); |
0 commit comments