Skip to content

Commit d913467

Browse files
committed
test(rest): pin export boolean cells to the caller's language
Red on main: an Accept-Language en-US caller gets the English header row over boolean cells written as the literal 是 / 否, in csv, xlsx and json. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78ab647 commit d913467

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

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' }] } as any);
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' }] } as any);
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)