Skip to content

Commit fb194c7

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) (#20517)
Fixes #20497 Clause-②: no ## What changes `parseNumberCell` in `packages/rest/src/import-coerce.ts` removed every comma before parsing, as if every comma grouped thousands. So `POST /api/v1/data/:object/import` stored a decimal-comma cell as a different number and reported success. A comma is now accepted only in a well-formed thousands group: 1 to 3 leading digits, then groups of exactly three, and only before any `.` (`1,000`, `12,345.67`). One anchored pattern, `THOUSANDS_GROUPED_INTEGER`, is tested before the strip, and the strip now runs only for that form. Every other comma makes the cell unparseable, so the row gets the importer's existing `invalid_number` error. That is the same code the plain write doors already answer for these cells. No locale is guessed, and there is no new error code and no decimal-separator option, as triage directed (`5877481993`). The rule is stated in the `parseNumberCell` docblock, where the reader's tolerances are listed. Measured through the real route (JSON rows, `writeMode: 'insert'`) on `InMemoryDriver` and on `SqlDriver` (better-sqlite3). Both drivers answered the same on every cell, at base `9449512a31` and at head `c76a3c95f2`: | cell | base: stored · import answer | head | plain `POST` (engine insert), both trees | |:--|:--|:--|:--| | `'3,14'` | `314` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | `VALIDATION_FAILED` / `invalid_number` | | `'1,5'` | `15` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | same | | `'1.000,5'` | `1.0005` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | same | | `'1,2,3'` | `123` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | same | | `'1,000'` | `1000` | `1000` (unchanged) | refused (the import-only tolerance stays) | | `'12,345.67'` | `12345.67` | `12345.67` (unchanged) | refused | | `'(1,234)'` | `-1234` | `-1234` (unchanged) | refused | ## PM hypotheses, measured - **H0 holds.** On current `main` (`9449512a31`), the four cells imported as `314`, `15`, `1.0005` and `123` with `ok 1, errors 0`, on `InMemoryDriver` and on SQLite. The table above has the readings. - **H1 holds.** The one line `s.replace(/,/g, '')` is the whole cause. Before/after census through `coerceRow`: before is rest's built `dist` at the base, after is the head's `src`. It covers 79 rows: the spec grammar's 41 `NUMERIC_STRING_GRAMMAR_CASES` rows, 18 documented or control forms, and 20 comma probes. - **Grammar rows.** 1 of 41 changed: `'1.000,5'` went from `1.0005` to refused. The other 7 grammar-refused rows the reader admits keep their reading: `' 12 '`, `'12\n'`, `'\t-3'`, `'1,000'`, `'+5'`, `'.5'` and `'007'`. - **Documented and control forms.** 0 of 18 changed: `1,234`, `$1,000`, `¥2,500.75`, `€1,000`, `£1,000`, `¥1,000`, `25%`, `(1,234)`, `(100)`, `1,234.5`, `12,345.67`, `1,000`, `-1,234`, `+1,234`, `1,234,567.89`, `$ 1,000`, `1,234%` and `1,000e3`. - **Comma probes.** 18 of 20 changed, each from a stripped number to a refusal: `3,14`, `1,5`, `1.000,5`, `1,2,3`, `0,5`, `1,23`, `1234,567`, `1,0000`, `,123`, `-,123`, `.5,000`, `1,000,`, `12,345.6,7`, `12,34,567`, `1,00,000`, `(3,14)`, `$1,5` and `1,5%`. The other 2 (`1,000.` and `1 ,000`) were already refused. - **Overall.** 19 rows changed, every one from a stored number to a refusal. No row moved the other way, and no admitted value changed. - **H2 holds.** A refused cell's row carries `code: 'invalid_number'`, the code the importer already uses for `abc`, with the importer's existing sentence (`Amount: "3,14" is not a number`, from the catalog's `import_invalid_number` key). The plain create door answers `400 VALIDATION_FAILED` with field code `invalid_number` for the same cells, and that is pinned beside the import pins. No new code. - **H3 holds, in the direction expected.** The ablation removed the grouping guard, which restores the unconditional strip. It ran via `scripts/ablation-replace.mjs` in wrap mode, at head `c76a3c95f2`. The anchor went 1 to 0 and the blob went `afaf602da192` to `a06963b8c44c`, so the mutation landed. Result: `Tests 24 failed | 46 passed (70)`. - **Red: every refused-cell assertion and nothing else.** That is 18 `parseNumberCell` refusal cases, the 4 per-cell import pins, the CSV leg and the dry-run leg. - **Green: every admitted control.** That is 3 import pins, 8 `parseNumberCell` admitted cases, and the plain-door parity pin. - **Restore proven.** Blob after restore equals HEAD (`afaf602da192`), `git diff HEAD` is empty, and the marker count is 0. No build was needed: the pins reach `import-coerce.ts` through relative imports (`./rest-server`, `./import-coerce`), never through a `dist/`. ## Tests - `packages/rest/src/import-number-thousands-group.test.ts` (new) goes through the real `/import` route over `SqlDriver` (better-sqlite3 `:memory:`). It has 10 cases: - each of the four cells is refused as its own row's `invalid_number`, with a sibling row still written; - each admitted control (`1,000`, `12,345.67`, `(1,234)`) is stored as its number; - the same verdicts hold for quoted CSV cells; - the dry run predicts the refusals and persists nothing; - the plain create door answers the same code. - `packages/rest/src/import-coerce.test.ts` gets the `parseNumberCell` case table: 8 admitted groupings and 18 refused comma forms. - At `c76a3c95f2`, run as `pnpm --filter @objectstack/rest test --maxWorkers=2`: `Test Files 220 passed (220)` and `Tests 4211 passed | 40 skipped (4251)`. `test:repo` passed 1 file with 8 tests. - `pnpm --filter @objectstack/rest typecheck` exits 0: `tsc --noEmit`, then `check:test-typecheck: OK`, with 0 errors. Both test files are in the `tsconfig.test.json` program (`--listFilesOnly`). ## Gates - **Build.** `turbo run build` ran first for `@objectstack/rest...`, then for all of `./packages/*` and `./packages/*/*`, because two gates read the whole built tree. Result: 71/71 tasks. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands at `c76a3c95f2`. All 61 ran with exit 0. Reconciled with `--ran`: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`. - **Other gates, all exit 0.** `pnpm lint` (the whole repository, 29 s, no narrowing) and `node scripts/check-issue-citations.mjs --base origin/main` (`origin/main` is still `9449512a31`, the branch point, so there was nothing to merge). - **Changeset gates.** `check-adr-0087-registration` names this PR's changeset as `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major` reports no `major`. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2 --output-logs=errors-only pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 --output-logs=errors-only pnpm --filter @objectstack/rest test --maxWorkers=2 pnpm --filter @objectstack/rest test:repo --maxWorkers=2 pnpm --filter @objectstack/rest typecheck pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 (the targeted pin files, and the ablation's wrapped run) (The entry point printed this wording once for each command above. It is pasted once here, with every command it covered.) ## Changeset `.changeset/20497-import-number-thousands-group.md`: `@objectstack/rest` `minor`, with a line-initial `Clause-②: no (narrowing)`, a BREAKING paragraph giving each refused cell shape FROM → TO, and the ADR-0087 disposition `not-required (no-migration-prescription)`. The shape follows PR #20218 and PR #20231. ## Acceptance notes - **The `InMemoryDriver` leg is measured, not pinned. This departs from triage's pin list.** Triage asked for `/import` pins on memory and SQLite. `@objectstack/driver-memory`'s test consumers are a ruled, ledgered set (`scripts/driver-memory-census.ledger.json`, gated by `pnpm check:driver-memory-census`), and the gate says a new consumer is a maintainer ruling. A first cut added the driver as a `packages/rest` devDependency with a source alias. The census gate refused it by name: `x LEDGERED: packages/rest/src/import-number-thousands-group.test.ts:31 binds @objectstack/driver-memory (import) and the ledger does not cover it`. That cut was withdrawn, so the diff is back to the claim's file surface. The cell is judged by the importer's reader before any driver is reached, so one verdict holds on every driver. The memory readings at base and head are recorded in the table above and in the pin's header. If a permanent memory arm is wanted, it goes through the census ruling first. - **Grouping by twos is now refused (`12,34,567`, `1,00,000`).** These used to import as the number they denote. The changeset names this as the one case where the narrowing refuses a cell that was read correctly before, because a two-digit group cannot be told apart from a decimal comma. - **The import template card #18386 should follow this wording.** Its value-domain row for `number` lists `1,234` among the tolerated forms. It should say that a comma is read only as a thousands group (1 to 3 leading digits, then groups of exactly three, only before any `.`), and that a decimal comma such as `3,14` is refused, never guessed. That card is assigned elsewhere and is not edited here. - **A spec comment now overstates the reader.** The module header of `packages/spec/src/data/filter-number-comparand-declared-type.ts` says, in the parenthetical under its refused digit-separator forms, that the CSV import route's cell reader strips such punctuation before it parses. For `1.000,5` that is no longer true. It was already untrue for `1_000` and `1 000`, which the reader refused before this change too. This is a comment in the spec seat's surface. There is no carrier, so it is noted here only. - **objectui's Import Wizard preview disagrees with the server on grouped numbers.** The preview judges numeric cells with a bare `Number()` (`packages/plugin-grid/src/ImportWizard.tsx` in objectui, the number/currency/percent case). So it flags `1,000` as invalid while the server admits it. That disagreement predates this PR and is unchanged by it. For the four cells here, preview and server now agree: both refuse. I read this in objectui's source and did not measure it through the UI. There is no carrier, so it is noted here only. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0bbe400 commit fb194c7

4 files changed

Lines changed: 289 additions & 1 deletion

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest): `POST /api/v1/data/:object/import` reads a comma in a number cell only as a thousands group, and refuses every other comma instead of storing a different number (#20497)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the import door.** A cell for a numeric field
10+
(`number`, `currency`, `percent` and the other numeric value types) that
11+
carries a comma is now admitted only when the comma groups thousands: 1 to 3
12+
leading digits, then groups of exactly three digits, and only before any `.`
13+
(`1,000`, `12,345.67`, `-1,234,567.89`). Any other
14+
comma makes the cell that row's `invalid_number` error, the same code the
15+
plain write doors (create, update, batch) already answer for the cell. The
16+
reader used to strip every comma before parsing, so each of these was stored
17+
as a DIFFERENT number while the import reported `ok 1, errors 0`. For each
18+
shape, change the cell FROM the refused spelling TO the one that says what you
19+
mean:
20+
21+
- **A decimal comma.** FROM `3,14`, `1,5`, `0,5`, `1.000,5` (stored as `314`,
22+
`15`, `5`, `1.0005`) TO a `.` decimal point with no grouping, or with comma
23+
grouping: `3.14`, `1.5`, `0.5`, `1000.5` or `1,000.5`. A file exported with a
24+
decimal-comma locale has to be converted before import. No locale is
25+
guessed: `1,500` is always one thousand five hundred.
26+
- **A comma that does not group thousands.** FROM `1,2,3`, `1,23`, `1,0000`,
27+
`1234,567`, `,123`, `1,000,` or a comma after the `.` (`12,345.6,7`), each
28+
stored with its commas removed, TO the number with no separators, or with
29+
well-formed thousands grouping.
30+
- **Grouping by twos (`12,34,567`, `1,00,000`).** FROM that grouping TO
31+
`1234567` / `100000`, or `1,234,567` / `100,000`. These used to import as
32+
the number they denote. They are refused now because the reader cannot tell
33+
a two-digit group from a decimal comma, and it no longer guesses.
34+
35+
**What is not affected.** Every cell without a comma reads exactly as before.
36+
That is measured over the platform numeric grammar's 41 case rows: the only
37+
row whose import reading changed is `1.000,5`. A well-formed thousands grouping
38+
reads as before, with or without a leading currency symbol, a trailing `%`, a
39+
sign or accounting parentheses (`$1,000`, `1,234%`, `(1,234)`). A JSON number,
40+
and an xlsx cell Excel stores as a number, never pass through this reading.
41+
The dry run answers the same verdicts as the real write. A refused cell fails
42+
only its own row, and the rest of the batch imports as before.
43+
44+
**If you are refused.** The row's result carries `code: 'invalid_number'` and
45+
quotes the cell, so the file can be corrected and re-imported.
46+
47+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. The import door's cell reader accepts fewer spellings of a number inside an imported file, which is data, not metadata, so `objectstack migrate meta` has nothing to reach. The row's invalid_number error quotes the refused cell, and the repair is to write the number with a `.` decimal point. -->

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,36 @@ describe('parseNumberCell', () => {
4949
expect(parseNumberCell('12x3')).toBeUndefined();
5050
expect(parseNumberCell('')).toBeUndefined();
5151
});
52+
53+
// [#20497] A comma is read only where it groups thousands: 1 to 3 leading
54+
// digits, then groups of exactly three, and only before any `.`.
55+
it.each([
56+
['1,000', 1000],
57+
['12,345.67', 12345.67],
58+
['1,234,567.89', 1234567.89],
59+
['-1,234', -1234],
60+
['(1,234)', -1234],
61+
['$1,000', 1000],
62+
['1,234%', 1234],
63+
['1,000e3', 1_000_000],
64+
])('admits the well-formed thousands grouping %j as %s', (cell, n) => {
65+
expect(parseNumberCell(cell)).toBe(n);
66+
});
67+
68+
it.each([
69+
// The card's four cells: stored as 314, 15, 1.0005 and 123 before.
70+
'3,14', '1,5', '1.000,5', '1,2,3',
71+
// A decimal comma, however it is dressed.
72+
'0,5', '(3,14)', '$1,5', '1,5%',
73+
// A group that is not exactly three digits, or a comma out of place.
74+
'1,23', '1,0000', '1234,567', ',123', '-,123', '1,000,', '.5,000',
75+
// A comma after the `.`.
76+
'12,345.6,7',
77+
// A grouping other than thousands — no locale is guessed.
78+
'12,34,567', '1,00,000',
79+
])('refuses %j rather than reading it as some other number', (cell) => {
80+
expect(parseNumberCell(cell)).toBeUndefined();
81+
});
5282
});
5383

5484
describe('parseDateCell', () => {

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,33 @@ export function parseBooleanCell(raw: unknown): boolean | undefined {
218218

219219
// ── numbers ────────────────────────────────────────────────────────
220220

221+
/**
222+
* The one comma placement a numeric cell may carry: a thousands grouping of the
223+
* integer part — an optional sign, 1 to 3 leading digits, then one or more
224+
* groups of exactly three digits, ending the integer part (end of cell, `.` or
225+
* the exponent marker follows) — and no comma anywhere after it. `1,000`,
226+
* `12,345.67`, `-1,234,567` match; `3,14`, `1,5`, `1.000,5`, `1,2,3`,
227+
* `1234,567`, `1,0000` and `12,345.6,7` do not.
228+
*/
229+
const THOUSANDS_GROUPED_INTEGER = /^[+-]?\d{1,3}(?:,\d{3})+(?![\d,])[^,]*$/;
230+
221231
/**
222232
* Parse a numeric cell, tolerating the punctuation spreadsheets add: thousands
223233
* separators (`1,234`), a leading currency symbol (`$` `¥` `€` `£` `¥`), a
224234
* trailing percent sign (`25%` → `25`), and accounting-style parenthesised
225235
* negatives (`(1,234)` → `-1234`). Returns `undefined` when the residue is not
226236
* a finite number.
237+
*
238+
* A comma is a thousands separator and nothing else, and only where it groups
239+
* thousands (#20497): 1 to 3 leading digits, then groups of exactly three,
240+
* and only before any `.` (`1,000`, `12,345.67`) — see
241+
* {@link THOUSANDS_GROUPED_INTEGER}. Any other comma makes the cell
242+
* unparseable (`undefined`, so the row's `invalid_number` error), never a
243+
* different number: a decimal-comma spelling (`3,14`, `1,5`, `1.000,5`) or a
244+
* stray comma (`1,2,3`) used to have every comma stripped and was stored as
245+
* `314`, `15`, `1.0005`, `123`. No locale is guessed — `1,500` is always one
246+
* thousand five hundred, never 1.5 — so a file written with a decimal comma is
247+
* refused cell by cell rather than read as some other value.
227248
*/
228249
export function parseNumberCell(raw: unknown): number | undefined {
229250
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
@@ -233,7 +254,10 @@ export function parseNumberCell(raw: unknown): number | undefined {
233254
if (/^\(.*\)$/.test(s)) { negative = true; s = s.slice(1, -1).trim(); }
234255
s = s.replace(/^[$¥€£¥]\s*/, ''); // leading currency symbol
235256
s = s.replace(/%$/, '').trim(); // trailing percent
236-
s = s.replace(/,/g, ''); // thousands separators
257+
if (s.includes(',')) {
258+
if (!THOUSANDS_GROUPED_INTEGER.test(s)) return undefined;
259+
s = s.replace(/,/g, ''); // a well-formed thousands grouping
260+
}
237261
if (s === '' || !/^[+-]?\d*\.?\d+(e[+-]?\d+)?$/i.test(s)) return undefined;
238262
const n = Number(s);
239263
if (!Number.isFinite(n)) return undefined;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20497] `POST /api/v1/data/:object/import` reads a comma in a number cell
5+
* as a thousands separator ONLY where it groups thousands — 1 to 3 leading
6+
* digits, then groups of exactly three, and only before any `.` — and refuses
7+
* every other comma as that row's `invalid_number` error, through the real
8+
* route over a real `SqlDriver` (better-sqlite3 `:memory:`).
9+
*
10+
* Measured through this route, JSON rows, `writeMode: 'insert'`, on
11+
* `InMemoryDriver` and on `SqlDriver` (better-sqlite3) alike, at the base
12+
* (`9449512a31`) and at the head of the PR that landed this file:
13+
*
14+
* | cell | base: stored · answer | head |
15+
* |:--|:--|:--|
16+
* | `'3,14'` | `314` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
17+
* | `'1,5'` | `15` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
18+
* | `'1.000,5'` | `1.0005` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
19+
* | `'1,2,3'` | `123` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
20+
* | `'1,000'` / `'12,345.67'` / `'(1,234)'` | `1000` / `12345.67` / `-1234` | unchanged |
21+
*
22+
* The `InMemoryDriver` row is this file's by construction, not by a second
23+
* arm: the cell is judged by the import's own reader (`parseNumberCell`)
24+
* before any driver is reached, so one verdict holds on every driver. A test
25+
* import of `@objectstack/driver-memory` is also not this file's to add — its
26+
* test consumers are a ruled, ledgered set
27+
* (`scripts/driver-memory-census.ledger.json`, `pnpm check:driver-memory-census`).
28+
*
29+
* The plain create door already answered `400 VALIDATION_FAILED` with field
30+
* code `invalid_number` for each of the four; the import row now answers the
31+
* same code. The reader's own case table (every documented form, the comma
32+
* probes) is `import-coerce.test.ts`'s `parseNumberCell` block; this file pins
33+
* the door.
34+
*/
35+
36+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
40+
import { RestServer } from './rest-server';
41+
42+
const OBJECT = 'import_thousands_20497';
43+
44+
const LEDGER = {
45+
name: OBJECT, label: 'Ledger 20497', systemFields: false,
46+
fields: {
47+
id: { name: 'id', type: 'text' as const, primaryKey: true },
48+
amount: { name: 'amount', type: 'number' as const, label: 'Amount' },
49+
},
50+
};
51+
52+
/** The card's four cells: each used to be stored as a different number. */
53+
const REFUSED = ['3,14', '1,5', '1.000,5', '1,2,3'] as const;
54+
55+
/** The admitted controls: a well-formed thousands grouping keeps its reading. */
56+
const ADMITTED: ReadonlyArray<readonly [cell: string, stored: number]> = [
57+
['1,000', 1000],
58+
['12,345.67', 12345.67],
59+
['(1,234)', -1234],
60+
];
61+
62+
function makeSqliteDriver() {
63+
return new SqlDriver({
64+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
65+
});
66+
}
67+
68+
function createMockServer() {
69+
const noop = () => {};
70+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
71+
}
72+
73+
function makeRes() {
74+
const res: any = {
75+
write: () => true, end: () => {},
76+
header: () => res,
77+
status: (code: number) => { res._status = code; return res; },
78+
json: (body: any) => { res._json = body; return res; },
79+
};
80+
return res;
81+
}
82+
83+
const liveEngines: ObjectQL[] = [];
84+
afterEach(async () => {
85+
while (liveEngines.length) {
86+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
87+
}
88+
});
89+
90+
async function boot() {
91+
const engine = new ObjectQL();
92+
liveEngines.push(engine);
93+
engine.registerDriver(makeSqliteDriver(), true);
94+
await engine.init();
95+
engine.registry.registerObject(LEDGER as any);
96+
await engine.syncSchemas();
97+
const protocol = new ObjectStackProtocolImplementation(engine as any);
98+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
99+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
100+
rest.registerRoutes();
101+
const find = (method: string, path: string) =>
102+
rest.getRoutes().find((r: any) => r.method === method && r.path === path) as any;
103+
const importRoute = find('POST', '/api/v1/data/:object/import');
104+
const createRoute = find('POST', '/api/v1/data/:object');
105+
expect(importRoute).toBeDefined();
106+
expect(createRoute).toBeDefined();
107+
const send = async (route: any, body: unknown) => {
108+
const res = makeRes();
109+
await route.handler({ params: { object: OBJECT }, body } as any, res);
110+
return res;
111+
};
112+
return {
113+
engine,
114+
importRows: (body: Record<string, unknown>) => send(importRoute, body),
115+
create: (body: Record<string, unknown>) => send(createRoute, body),
116+
};
117+
}
118+
119+
describe('[#20497] /import — a comma is read only as a thousands group', () => {
120+
let ctx: Awaited<ReturnType<typeof boot>>;
121+
beforeEach(async () => { ctx = await boot(); });
122+
123+
it.each(REFUSED)('refuses %j as that row\'s invalid_number error and writes its sibling row', async (cell) => {
124+
const res = await ctx.importRows({
125+
format: 'json', writeMode: 'insert',
126+
rows: [{ id: 'bad', amount: cell }, { id: 'good', amount: 7 }],
127+
});
128+
129+
expect(res._status ?? 200).toBe(200);
130+
expect(res._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 });
131+
expect(res._json.results[0]).toMatchObject({
132+
row: 1, ok: false, action: 'failed', field: 'amount', code: 'invalid_number',
133+
});
134+
// Refused, not stored as some other number.
135+
expect(await ctx.engine.findOne(OBJECT, { where: { id: 'bad' } })).toBeNull();
136+
expect((await ctx.engine.findOne(OBJECT, { where: { id: 'good' } }))?.amount).toBe(7);
137+
});
138+
139+
it.each(ADMITTED)('admits the thousands grouping %j and stores %s', async (cell, stored) => {
140+
const res = await ctx.importRows({
141+
format: 'json', writeMode: 'insert',
142+
rows: [{ id: 'r', amount: cell }],
143+
});
144+
145+
expect(res._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 });
146+
expect(res._json.results[0]).toMatchObject({ row: 1, ok: true, action: 'created' });
147+
expect((await ctx.engine.findOne(OBJECT, { where: { id: 'r' } }))?.amount).toBe(stored);
148+
});
149+
150+
it('gives the same verdicts to quoted CSV cells', async () => {
151+
const csv = [
152+
'ID,Amount',
153+
...REFUSED.map((cell, i) => `r${i},"${cell}"`),
154+
...ADMITTED.map(([cell], j) => `c${j},"${cell}"`),
155+
].join('\n');
156+
const res = await ctx.importRows({ format: 'csv', csv, writeMode: 'insert', mapping: { ID: 'id', Amount: 'amount' } });
157+
158+
expect(res._json).toMatchObject({ total: REFUSED.length + ADMITTED.length, ok: ADMITTED.length, errors: REFUSED.length });
159+
const failedFields = res._json.results.filter((r: any) => !r.ok).map((r: any) => [r.field, r.code]);
160+
expect(failedFields).toEqual(REFUSED.map(() => ['amount', 'invalid_number']));
161+
for (const [j, [, stored]] of ADMITTED.entries()) {
162+
expect((await ctx.engine.findOne(OBJECT, { where: { id: `c${j}` } }))?.amount).toBe(stored);
163+
}
164+
});
165+
166+
it('dry run predicts the same refusals and persists nothing', async () => {
167+
const res = await ctx.importRows({
168+
format: 'json', writeMode: 'insert', dryRun: true,
169+
rows: REFUSED.map((amount, i) => ({ id: `d${i}`, amount })),
170+
});
171+
172+
expect(res._json).toMatchObject({ dryRun: true, total: REFUSED.length, ok: 0, errors: REFUSED.length });
173+
for (const r of res._json.results) expect(r).toMatchObject({ ok: false, field: 'amount', code: 'invalid_number' });
174+
for (const [i] of REFUSED.entries()) {
175+
expect(await ctx.engine.findOne(OBJECT, { where: { id: `d${i}` } })).toBeNull();
176+
}
177+
});
178+
179+
it('answers the code the plain create door answers for the same cells', async () => {
180+
for (const [i, amount] of REFUSED.entries()) {
181+
const res = await ctx.create({ id: `p${i}`, amount });
182+
expect(res._status).toBe(400);
183+
expect(res._json).toMatchObject({ code: 'VALIDATION_FAILED' });
184+
expect(res._json.fields[0]).toMatchObject({ field: 'amount', code: 'invalid_number' });
185+
}
186+
});
187+
});

0 commit comments

Comments
 (0)