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