Skip to content

Commit 86d2360

Browse files
fix(rest): export boolean cells follow the caller's language (#24998)
Fixes #24945 Clause-②: no ## What changes `GET /api/v1/data/:object/export` wrote every boolean or toggle cell as the literal `是` / `否`. The header row of the same file follows the caller's locale, so an `Accept-Language: en-US` caller got "Converted" and "Do Not Call" over Chinese cells. The cell now follows the same locale as the header. - `packages/rest/src/export-format.ts` - `formatCellValue`, `formatRowCells` and `formatRowForJson` take `booleanWords` beside `timezone`. The boolean / toggle branch writes `booleanWords[0]` / `booleanWords[1]`. - The words have one source: `templateText(locale).booleanWords` in `import-template.ts`, the table the import template already uses (`yes` / `no`, or `是` / `否` for a `zh*` locale). The new `ExportBooleanWords` type is that table's own field type. No second table. - Without words, the formatter writes the pair `templateText(undefined)` answers (`yes` / `no`). That is the same answer the import template gives a request with no locale. - `packages/rest/src/data-action-routes.ts`, the export route - It resolves the request's i18n service once (`resolveI18nService`, then `extractLocale(req, i18n)`). That is the same read `answerImportTemplate` uses. It computes the words once per export from that locale. - It hands the same i18n service to `translateMetaItem` (its existing optional fifth argument). So the header translation and the words read one locale, from one service: `Accept-Language`, then `?locale=`, then the service's default locale. - csv, xlsx and json all pass the words through. - `packages/rest/src/rest-server.ts`. This file is outside the claimed file surface. See Deviations. - The `DataActionRouteHost` literal gains `resolveI18nService` and `extractLocale`. The route had no locale in hand: the header's locale was resolved inside `translateMetaItem` and never left it. - `rowsToCsv`, the csv arm's serializer, lives here. It takes `booleanWords` after `timezone` and passes them to `formatRowCells`. - `packages/rest/src/import-template.ts`: one import line. `buildFieldMetaMap` / `ExportFieldMeta` now come from `@objectstack/core`, where they are defined, and no longer through `export-format.ts`'s re-export. `export-format.ts` now imports `templateText` from here, and this keeps the two modules from importing each other. - No key, query parameter, accept set, header, column set or payload shape moves. Only the text of a boolean cell changes. The public declaration file gains nothing: in the built `dist/index.d.ts`, `booleanWords`, `ExportBooleanWords`, `templateText` and `rowsToCsv` each count 0 hits, and the control `ExportFieldMeta` counts 3. ## Readings on the mechanism assumptions All readings come from the real export and import routes, on a real ObjectQL engine and protocol with a sqlite `:memory:` driver (`export-boolean-locale.test.ts`). 1. **Main writes `是` / `否` to an English caller, in every format.** At base `78ab6474ca`, an `en-US` csv export reads header `ID,Full Name,Converted,Do Not Call`, then row `1,Wren Halvorsen,是,否`. xlsx gives the same cells. json gives `"是"` / `"否"` as strings: the json arm runs the same branch through `formatRowForJson`, so it never kept raw `true` / `false`. With this PR, json writes the caller's words as strings. It does not switch to raw booleans, because that would move a payload shape. 2. **zh-CN and no locale.** - A `zh-CN` caller keeps `是` / `否`, under the Chinese header. - `templateText(undefined)` answers the English table, so a request with no locale gets `yes` / `no`. - The header with no locale is the authored label, untranslated. It agrees with the cells only when the labels are authored in English (the pinned fixture). A deployment whose i18n service declares a default locale resolves a header-less request to that default for both halves. This is pinned for a `zh-CN` default and for an `en` default. 3. **Re-import, both languages.** The importer (`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS` in `packages/spec`) reads `yes` / `no` and `是` / `否`. Each language's csv and xlsx export, posted to `POST /api/v1/data/:object/import` into a fresh store, reads back as `[true, false]` / `[false, true]`. All four pins are green. 4. **#2481 and #2916.** #2481 (the feature that added the formatter) lists "boolean: `是` / `否`" in its plan. It was written before the export resolved any locale, it has no comments, and its author is a contributor. #2916 made export and import follow the caller's language and does not mention booleans. Neither is a maintainer ruling that booleans are `是` / `否` for every locale. ## Evidence - **Red on main.** At `d913467d6e`, which is the new test file alone on base `78ab6474ca`: `Tests 5 failed | 6 passed (11)`. The red pins are en csv, the xlsx pair, the json pair, the default-locale pin (its `en` half) and the no-i18n pin. The 6 green ones are the zh csv control, `?locale=zh-CN`, and the four re-import pins: main's `是` / `否` file re-imports. - **Green.** `export-boolean-locale.test.ts`: `Tests 11 passed (11)`. The 11 affected export / import files: `Tests 620 passed (620)`. - **Ablation A: the locale handed to the words.** It replaced `templateText(this.extractLocale(req, i18n)).booleanWords` with `templateText(undefined).booleanWords` through `scripts/ablation-replace.mjs`, on `data-action-routes.ts` (anchor 1 → 0, blob `53f7b7e9` → `5c3ec476`). - Result: `Tests 5 failed | 6 passed (11)`. Every zh pin went red: csv, the xlsx and json pairs, `?locale=`, and the default-locale pin. - The en pin stayed green. That is the direction the code predicts: with the locale dropped, the cells fall to `templateText(undefined)`, which is the English pair. So the en pin's red reading is the main-red above, not this ablation. - Restored: blob == HEAD `53f7b7e9`, and `git diff HEAD` is empty. - **Ablation B: one i18n service for both halves.** It replaced `this.extractLocale(req, i18n)` with `this.extractLocale(req, undefined)` (blob `53f7b7e9` → `8299404a`). - Result: `Tests 1 failed | 10 passed (11)`. The red pin is the default-locale one: a `zh-CN`-default deployment wrote the header `编号,姓名,已转化,请勿致电` over `yes` / `no` cells. - Restored: blob == HEAD `53f7b7e9`, and `git diff HEAD` is empty. - Both ablations exercise this package's own `src/`, reached by relative import from the test. No `dist/` rebuild was needed. ## Fixture triage The existing pins on `是` / `否` named no locale. Each was triaged by its fixture's language, so its header and cells keep one language: - **Chinese fixtures now name a `zh-CN` caller.** These are `export-integration.test.ts`, `rest.test.ts`'s mocked export suite, and `import-template-route.test.ts`'s `exportOnce`. - The last of these holds the recorded export bytes (csv, json, xlsx by sha256), and it stays byte-identical with that caller. - So a Chinese caller's file is exactly what it was before this PR. - **English fixture.** `export-json-fields.test.ts` (labels `ID` / `Title`, no locale) now expects `yes` / `no`. ## Gates (final head `3d3bafba28`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands. All 68 exited 0. - The 10 families under "Declared WIDE population" also exited 0. These are `check:init-service-contract`, `live-db-isolation`, `meta-type-normalized`, `optional-error-sink`, `resume-authority-declared`, `runner-env-posture`, `settings-bind-window`, `startup-registry-verdict`, `verify-stand-in` and `wildcard-fallthrough`. - The `--ran` reconciliation: `✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).` - `pnpm lint` (`eslint . --no-inline-config`): exit 0, no findings printed. - `pnpm --filter @objectstack/rest exec vitest run --project local`: `Test Files 307 passed (307)`, `Tests 6165 passed | 326 skipped (6491)`. - `pnpm --filter @objectstack/rest typecheck`: exit 0. The test layer compiles the new file: `--listFiles` counts `export-boolean-locale.test.ts` once. - An earlier head red `check:query-options-erasure` (test surface 236 → 238). The cause was two `as any` casts on the re-import pins' `engine.find` options. They are typed now, and the gate is green at the final head. ## Deviations - **File surface.** The claim's surface did not list `packages/rest/src/rest-server.ts`. The fix needs it twice: once for the host wiring, because the route had no locale, and once for `rowsToCsv`, because the csv serializer lives there. The edits come to two host entries, one import name and the `rowsToCsv` parameter with its doc. ## Acceptance notes - **No i18n service and no locale.** The cells read `yes` / `no` while the header shows the authored labels. A deployment with Chinese-authored labels, no i18n service and a header-less caller would show a Chinese header over English words. The import template behaves the same way in that case (`templateText(undefined)`). A deployment that registers the i18n service gets its default locale for both halves. - **json carries strings.** json boolean cells are display strings (`"yes"` / `"是"`), as they were (`"是"`) before this PR. That is the formatting layer's existing design, unchanged here. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbb42d9 commit 86d2360

10 files changed

Lines changed: 367 additions & 30 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): a boolean cell in the list export is written in the caller's language (#24945)
6+
7+
Clause-②: no
8+
9+
`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX / JSON) wrote every boolean or toggle cell as `是` or `否`, whatever the caller read. The header row of the same file follows the caller's locale, so an English caller got "Converted" and "Do Not Call" over Chinese cells.
10+
11+
- **Now.** A boolean or toggle cell is written in the words the import template already uses for the caller's locale: `yes` / `no`, or `是` / `否` for a `zh*` locale. The locale is the one the header row is translated to: `Accept-Language`, then `?locale=`, then the deployment's default locale. The header and the cells always read one language. This applies to csv, xlsx and json.
12+
- **No locale at all.** When the request names no locale and the deployment declares no default, the cells read `yes` / `no`, the same words the import template writes then.
13+
- **Re-import.** A file exported in either language re-imports its boolean column: the importer reads `yes` / `no` and `是` / `否`.
14+
- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a boolean cell changes, and a Chinese caller's file is byte-for-byte what it was.

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import {
6868
type ExportCellMeta,
6969
} from './export-format.js';
7070
import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js';
71-
import { readTemplateMode, templateFieldDefs } from './import-template.js';
71+
import { readTemplateMode, templateFieldDefs, templateText } from './import-template.js';
7272
import { refuseRepeatedQueryParams } from './query-multiplicity.js';
7373
import { refuseUnknownQueryParams } from './query-allowlist.js';
7474
import { handleRouteError, logUnexpectedRouteError, mapDataError, nestedRefusalEnvelope } from './error-response.js';
@@ -200,6 +200,8 @@ export interface DataActionRouteHost {
200200
translateMetaItem: RestServer['translateMetaItem'];
201201
resolveMessageTranslator: RestServer['resolveMessageTranslator'];
202202
resolveSecurityService: RestServer['resolveSecurityService'];
203+
resolveI18nService: RestServer['resolveI18nService'];
204+
extractLocale: RestServer['extractLocale'];
203205
answerImportTemplate: RestServer['answerImportTemplate'];
204206
}
205207

@@ -884,8 +886,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
884886
//
885887
// Values are formatted for readability from the object schema: lookup /
886888
// user fields resolve to a name (via injected $expand), select fields to
887-
// their option label, booleans to 是/否, dates to YYYY-MM-DD. When the
888-
// schema is unavailable the raw stored values stream through unchanged.
889+
// their option label, booleans to the caller's yes / no words, dates to
890+
// YYYY-MM-DD. When the schema is unavailable the raw stored values stream
891+
// through unchanged.
892+
//
893+
// [#24945] Boolean / toggle cells are written in the language of the
894+
// header row: `templateText(locale).booleanWords` (yes / no, or 是 / 否 for
895+
// a `zh*` locale), for the locale the header labels are translated to
896+
// (`Accept-Language`, then `?locale=`, then the i18n service's default).
897+
// The importer reads both languages back, so the file re-imports.
889898
//
890899
// [#8373] `datetime` cells render in the caller's BUSINESS timezone
891900
// (`ExecutionContext.timezone`), so the file agrees with the screen;
@@ -1084,7 +1093,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
10841093
const fieldsExplicit = !!fields && fields.length > 0;
10851094

10861095
// Field metadata drives readable formatting (lookup names, select
1087-
// labels, 是/否, formatted dates) and the $expand that resolves
1096+
// labels, yes / no words, formatted dates) and the $expand that resolves
10881097
// references. Best-effort: when the schema is unavailable the export
10891098
// falls back to raw values, byte-identical to the un-formatted path.
10901099
let metaMap = new Map<string, ExportCellMeta>();
@@ -1111,13 +1120,23 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
11111120
}
11121121
return schema;
11131122
};
1123+
// [#24945] The file's language, read once for both of its halves:
1124+
// the header labels below are translated with THIS i18n service,
1125+
// which `extractLocale` also reads the request's locale from (its
1126+
// default locale is the last fallback), and the boolean cells are
1127+
// written in the words of the same locale — the pair the import
1128+
// template offers (`templateText`, `./import-template.ts`). One
1129+
// service and one locale, so the header and the cells cannot read
1130+
// two different languages.
1131+
const i18n = await this.resolveI18nService(environmentId, req).catch(() => undefined);
1132+
const booleanWords = templateText(this.extractLocale(req, i18n)).booleanWords;
11141133
try {
11151134
let schema: any = await readObjectSchema(objectName);
11161135
// Localize field labels to the request locale (Accept-Language /
11171136
// `?locale=`) the same way the metadata endpoints do, so the
11181137
// export header row matches the UI column headers instead of
11191138
// leaking the raw, untranslated `field.label` values.
1120-
schema = await this.translateMetaItem(req, 'object', environmentId, schema);
1139+
schema = await this.translateMetaItem(req, 'object', environmentId, schema, i18n);
11211140
if (typeof schema?.label === 'string' && schema.label.length > 0) {
11221141
objectLabel = schema.label;
11231142
}
@@ -1291,15 +1310,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
12911310
}
12921311

12931312
if (format === 'csv') {
1294-
const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone);
1313+
const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone, booleanWords);
12951314
res.write(text);
12961315
} else if (format === 'xlsx') {
12971316
if (firstChunk && includeHeader) {
12981317
xlsx!.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
12991318
}
13001319
const cols = fields ?? [];
13011320
for (const row of rows) {
1302-
const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone));
1321+
const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone, booleanWords));
13031322
if (styled) {
13041323
cols.forEach((f, i) => {
13051324
const argb = cellFontColor(row?.[f], metaMap.get(f));
@@ -1320,7 +1339,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
13201339
for (let i = 0; i < rows.length; i++) {
13211340
const prefix = (firstChunk && i === 0) ? '' : ',';
13221341
const row = fieldsExplicit ? pickExportKeys(rows[i], fields!) : rows[i];
1323-
res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone)));
1342+
res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone, booleanWords)));
13241343
}
13251344
}
13261345
firstChunk = false;
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24945] A boolean or toggle cell of `GET /data/:object/export` is written in
5+
* the caller's language — the same language as the header row of that file.
6+
*
7+
* The export formatter used to write the literal `是` / `否` for every boolean
8+
* cell, whatever the caller read: an `Accept-Language: en-US` caller got the
9+
* English header row ("Converted", "Do Not Call") over Chinese cells. The words
10+
* now come from the one table the import template already offers them from,
11+
* `templateText(locale).booleanWords` (`import-template.ts`), for the locale the
12+
* route resolves for its header labels — the request's i18n service read once
13+
* and handed to both the header translation and the words, so the two halves of
14+
* the file cannot read two different locales.
15+
*
16+
* The file must stay importable in either language: the last block exports in
17+
* each language and re-imports the file through the real import route into a
18+
* fresh store, so the boolean column has to read back as the values it left as.
19+
*
20+
* Driven end to end: the real export and import routes, a real ObjectQL engine
21+
* and protocol, and a real sqlite `:memory:` driver (the harness of
22+
* `export-integration.test.ts`).
23+
*/
24+
25+
import { describe, it, expect, afterEach } from 'vitest';
26+
import { ObjectQL } from '@objectstack/objectql';
27+
import { SqlDriver } from '@objectstack/driver-sql';
28+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
29+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
30+
import { RestServer } from './rest-server';
31+
import { templateText } from './import-template.js';
32+
import { loadXlsxWorkbook } from './xlsx-test-loader.js';
33+
34+
const liveEngines: ObjectQL[] = [];
35+
afterEach(async () => {
36+
while (liveEngines.length) {
37+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
38+
}
39+
});
40+
41+
/** Authored in English; the zh-CN bundle below translates every label. */
42+
const LEAD = {
43+
name: 'lead',
44+
label: 'Lead',
45+
systemFields: false,
46+
fields: {
47+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
48+
full_name: { name: 'full_name', type: 'text' as const, label: 'Full Name' },
49+
is_converted: { name: 'is_converted', type: 'boolean' as const, label: 'Converted' },
50+
do_not_call: { name: 'do_not_call', type: 'toggle' as const, label: 'Do Not Call' },
51+
},
52+
};
53+
54+
const BUNDLE: Record<string, unknown> = {
55+
'zh-CN': {
56+
objects: {
57+
lead: {
58+
label: '线索',
59+
fields: {
60+
id: { label: '编号' },
61+
full_name: { label: '姓名' },
62+
is_converted: { label: '已转化' },
63+
do_not_call: { label: '请勿致电' },
64+
},
65+
},
66+
},
67+
},
68+
};
69+
70+
/** An i18n service of the shape `@objectstack/service-i18n` serves. */
71+
function i18nService(defaultLocale?: string) {
72+
return {
73+
getLocales: () => Object.keys(BUNDLE),
74+
getTranslations: (locale: string) => BUNDLE[locale],
75+
...(defaultLocale ? { getDefaultLocale: () => defaultLocale } : {}),
76+
};
77+
}
78+
79+
const ROWS = [
80+
{ id: '1', full_name: 'Wren Halvorsen', is_converted: true, do_not_call: false },
81+
{ id: '2', full_name: 'Tomas Okafor', is_converted: false, do_not_call: true },
82+
];
83+
84+
const EN_HEADER = ['ID', 'Full Name', 'Converted', 'Do Not Call'];
85+
const ZH_HEADER = ['编号', '姓名', '已转化', '请勿致电'];
86+
const EN_CELLS = [['1', 'Wren Halvorsen', 'yes', 'no'], ['2', 'Tomas Okafor', 'no', 'yes']];
87+
const ZH_CELLS = [['1', 'Wren Halvorsen', '是', '否'], ['2', 'Tomas Okafor', '否', '是']];
88+
89+
function createMockServer() {
90+
const noop = () => {};
91+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
92+
}
93+
94+
function makeRes() {
95+
const chunks: Buffer[] = [];
96+
const headers: Record<string, string> = {};
97+
const res: any = {
98+
write: (c: any) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))); return true; },
99+
end: () => {},
100+
header: (n: string, v: string) => { headers[n] = v; return res; },
101+
status: (code: number) => { res._status = code; return res; },
102+
json: (body: any) => { res._json = body; return res; },
103+
};
104+
return { res, headers, text: () => Buffer.concat(chunks).toString('utf8'), buffer: () => Buffer.concat(chunks) };
105+
}
106+
107+
/**
108+
* Boot one engine + REST server. `i18n` is the request's i18n service:
109+
* `null` leaves the server's own resolution in place (none in this harness).
110+
*/
111+
async function boot(i18n: unknown, rows: ReadonlyArray<Record<string, unknown>> = ROWS) {
112+
const engine = new ObjectQL();
113+
liveEngines.push(engine);
114+
engine.registerDriver(new SqlDriver({
115+
client: 'better-sqlite3',
116+
connection: { filename: ':memory:' },
117+
useNullAsDefault: true,
118+
}), true);
119+
await engine.init();
120+
engine.registry.registerObject(LEAD as any);
121+
await engine.syncSchemas();
122+
for (const row of rows) await engine.insert('lead', { ...row });
123+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
124+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
125+
}
126+
const protocol = new ObjectStackProtocolImplementation(engine as any);
127+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
128+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
129+
if (i18n !== null) (rest as any).resolveI18nService = async () => i18n;
130+
rest.registerRoutes();
131+
const routes = rest.getRoutes();
132+
const exportRoute = routes.find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export')!;
133+
const importRoute = routes.find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import')!;
134+
return { engine, exportRoute, importRoute };
135+
}
136+
137+
async function exportFile(
138+
route: any,
139+
format: 'csv' | 'xlsx' | 'json',
140+
opts: { acceptLanguage?: string; locale?: string } = {},
141+
) {
142+
const { res, text, buffer } = makeRes();
143+
await route.handler({
144+
params: { object: 'lead' },
145+
query: { format, ...(opts.locale ? { locale: opts.locale } : {}) },
146+
headers: opts.acceptLanguage ? { 'accept-language': opts.acceptLanguage } : {},
147+
} as any, res);
148+
return { text, buffer };
149+
}
150+
151+
function csvRows(text: string): string[][] {
152+
return text.split('\r\n').filter((l) => l.length > 0).map((l) => l.split(','));
153+
}
154+
155+
async function xlsxRows(buffer: Buffer): Promise<string[][]> {
156+
const ws = (await loadXlsxWorkbook(buffer)).worksheets[0];
157+
const out: string[][] = [];
158+
ws.eachRow((row) => { out.push((row.values as unknown[]).slice(1).map((v) => String(v))); });
159+
return out;
160+
}
161+
162+
describe('export: a boolean cell speaks the language of its header row', () => {
163+
it('an English caller gets yes / no under the English header (csv)', async () => {
164+
const { exportRoute } = await boot(i18nService('en'));
165+
const rows = csvRows((await exportFile(exportRoute, 'csv', { acceptLanguage: 'en-US' })).text());
166+
expect(rows).toEqual([EN_HEADER, ...EN_CELLS]);
167+
});
168+
169+
it('a Chinese caller gets 是 / 否 under the Chinese header (csv)', async () => {
170+
const { exportRoute } = await boot(i18nService('en'));
171+
const rows = csvRows((await exportFile(exportRoute, 'csv', { acceptLanguage: 'zh-CN' })).text());
172+
expect(rows).toEqual([ZH_HEADER, ...ZH_CELLS]);
173+
});
174+
175+
it('xlsx writes the same words as csv, per caller', async () => {
176+
const { exportRoute } = await boot(i18nService('en'));
177+
expect(await xlsxRows((await exportFile(exportRoute, 'xlsx', { acceptLanguage: 'en-US' })).buffer()))
178+
.toEqual([EN_HEADER, ...EN_CELLS]);
179+
expect(await xlsxRows((await exportFile(exportRoute, 'xlsx', { acceptLanguage: 'zh-CN' })).buffer()))
180+
.toEqual([ZH_HEADER, ...ZH_CELLS]);
181+
});
182+
183+
it('json writes the same words as csv, per caller', async () => {
184+
const { exportRoute } = await boot(i18nService('en'));
185+
const en = JSON.parse((await exportFile(exportRoute, 'json', { acceptLanguage: 'en-US' })).text());
186+
expect(en.map((r: any) => [r.is_converted, r.do_not_call])).toEqual([['yes', 'no'], ['no', 'yes']]);
187+
const zh = JSON.parse((await exportFile(exportRoute, 'json', { acceptLanguage: 'zh-CN' })).text());
188+
expect(zh.map((r: any) => [r.is_converted, r.do_not_call])).toEqual([['是', '否'], ['否', '是']]);
189+
});
190+
191+
it('`?locale=` picks the language of the header and the cells alike', async () => {
192+
const { exportRoute } = await boot(i18nService('en'));
193+
const rows = csvRows((await exportFile(exportRoute, 'csv', { locale: 'zh-CN' })).text());
194+
expect(rows).toEqual([ZH_HEADER, ...ZH_CELLS]);
195+
});
196+
197+
it('a caller naming no locale gets the deployment default locale in the header and the cells alike', async () => {
198+
// The default locale is read off the i18n service. Both halves of the
199+
// file must read it from the SAME service, or a zh-CN deployment would
200+
// write a Chinese header over English cells to every header-less caller.
201+
const zh = await boot(i18nService('zh-CN'));
202+
expect(csvRows((await exportFile(zh.exportRoute, 'csv')).text())).toEqual([ZH_HEADER, ...ZH_CELLS]);
203+
const en = await boot(i18nService('en'));
204+
expect(csvRows((await exportFile(en.exportRoute, 'csv')).text())).toEqual([EN_HEADER, ...EN_CELLS]);
205+
});
206+
207+
it('no locale at all and no i18n service: the authored header and templateText(undefined) words', async () => {
208+
const { exportRoute } = await boot(null);
209+
const [yes, no] = templateText(undefined).booleanWords;
210+
const rows = csvRows((await exportFile(exportRoute, 'csv')).text());
211+
expect(rows).toEqual([EN_HEADER, ['1', 'Wren Halvorsen', yes, no], ['2', 'Tomas Okafor', no, yes]]);
212+
});
213+
});
214+
215+
describe('export → import: each language re-imports its boolean column', () => {
216+
const MAPPINGS: Record<'en' | 'zh', Record<string, string>> = {
217+
en: { 'ID': 'id', 'Full Name': 'full_name', 'Converted': 'is_converted', 'Do Not Call': 'do_not_call' },
218+
zh: { '编号': 'id', '姓名': 'full_name', '已转化': 'is_converted', '请勿致电': 'do_not_call' },
219+
};
220+
221+
for (const [lang, acceptLanguage] of [['en', 'en-US'], ['zh', 'zh-CN']] as const) {
222+
it(`${lang}: csv`, async () => {
223+
const source = await boot(i18nService('en'));
224+
const csv = (await exportFile(source.exportRoute, 'csv', { acceptLanguage })).text();
225+
226+
const target = await boot(i18nService('en'), []);
227+
const { res } = makeRes();
228+
await target.importRoute.handler({
229+
params: { object: 'lead' },
230+
headers: { 'accept-language': acceptLanguage },
231+
body: { format: 'csv', csv, mapping: MAPPINGS[lang] },
232+
} as any, res);
233+
expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 });
234+
const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] });
235+
expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call]))
236+
.toEqual([['1', true, false], ['2', false, true]]);
237+
});
238+
239+
it(`${lang}: xlsx`, async () => {
240+
const source = await boot(i18nService('en'));
241+
const xlsxBase64 = (await exportFile(source.exportRoute, 'xlsx', { acceptLanguage })).buffer().toString('base64');
242+
243+
const target = await boot(i18nService('en'), []);
244+
const { res } = makeRes();
245+
await target.importRoute.handler({
246+
params: { object: 'lead' },
247+
headers: { 'accept-language': acceptLanguage },
248+
body: { format: 'xlsx', xlsxBase64, mapping: MAPPINGS[lang] },
249+
} as any, res);
250+
expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 });
251+
const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] });
252+
expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call]))
253+
.toEqual([['1', true, false], ['2', false, true]]);
254+
});
255+
}
256+
});

0 commit comments

Comments
 (0)