import assert from 'node:assert/strict'; import test from 'node:test'; import ExcelJS from 'exceljs'; import JSZip from 'jszip'; import { parseProgramInput } from '../src/program-parser.js'; import { PassengerRosterWorkbookError, normalizePassengerRosterWorkbook } from '../src/passenger-roster-workbook.js'; const SOURCE_HEADERS = [ '序号', '姓名', '英文姓名', '性别', '身份证号码', '出生日期', '年龄', '出生地', '护照号码', '签发地', '签发日期', '有效期', '电话', '备注' ] as const; const ERP_HEADERS = [ '序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码', '签发地', '签发日', '有效期', '电话', '备注' ] as const; const CANONICAL_HEADER = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注'; const REORDERED_SOURCE_INDEXES = [8, 1, 13, 0, 10, 4, 6, 12, 3, 11, 5, 9, 2, 7]; const ATTACHMENT_VARIANT_INDEXES = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 4]; type WorkbookMutation = (workbook: ExcelJS.Workbook) => void | Promise; const genderTemplate = (ref: string): string => `IF(OR(LEN(${ref})=15,LEN(${ref})=18),IF(MOD(MID(${ref},15,3)*1,2),"男","女"),NA())`; const birthTemplate = (ref: string): string => `DATE(MID(${ref},7,VLOOKUP(LEN(${ref}),{15,2;18,4},2,0)),MID(${ref},VLOOKUP(LEN(${ref}),{15,9;18,11},2,0),2),MID(${ref},VLOOKUP(LEN(${ref}),{15,11;18,13},2,0),2))`; const SYNTHETIC_ID = '11111119900102001X'; const normalizeSynthetic = (content: Buffer) => normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); const identityFormulas: WorkbookMutation = workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('E3').value = SYNTHETIC_ID; sheet.getCell('D3').value = { formula: genderTemplate('E3'), result: '男' }; sheet.getCell('F3').value = { formula: birthTemplate('E3'), result: new Date(Date.UTC(1990, 0, 2)) }; }; async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); sheet.addRow(['脱敏团队名单模板']); sheet.addRow([...SOURCE_HEADERS]); sheet.addRow([ 1, '测试甲', 'ALPHA,TEST', '男', '', '1990/1/2', { formula: 'DATEDIF(F3,TODAY(),"Y")', result: 36 }, '测试地一', 123456789, '测试签发地', new Date(Date.UTC(2025, 0, 2)), { formula: 'EDATE(K3,10*12)-1', result: new Date(Date.UTC(2035, 0, 1)) }, 13800000000, '领队\t备注' ]); sheet.addRow([ 2, '测试乙', 'BETA,TEST', '女', '', 36527, { formula: 'DATEDIF(F4,TODAY(),"Y")', result: 26 }, '测试地二', 'P00000002', '测试签发地', 45659, 49310, '', '' ]); for (const mutation of mutations) await mutation(workbook); return Buffer.from(await workbook.xlsx.writeBuffer()); } async function erpHeaderWorkbook(...mutations: WorkbookMutation[]): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('ERP名单'); sheet.addRow([...ERP_HEADERS]); sheet.addRow([ 1, '测试甲', 'ALPHA,TEST', '男', '1990/1/2', '测试地一', '护照', 123456789, '测试签发地', new Date(Date.UTC(2025, 0, 2)), new Date(Date.UTC(2035, 0, 1)), 13800000000, '领队\t备注' ]); for (const mutation of mutations) await mutation(workbook); return Buffer.from(await workbook.xlsx.writeBuffer()); } async function expectRosterError( work: () => Promise, code: string, location?: { row?: number; column?: number } ): Promise { try { await work(); } catch (error) { assert.ok(error instanceof PassengerRosterWorkbookError, String(error)); assert.equal(error.code, code); if (location?.row !== undefined) assert.equal(error.row, location.row); if (location?.column !== undefined) assert.equal(error.column, location.column); return error; } assert.fail(`expected ${code}`); } test('XLSX is deterministically normalized to the canonical 13-column TSV', async () => { const result = await normalizePassengerRosterWorkbook({ content: await syntheticWorkbook(), fileName: 'synthetic-roster.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); assert.equal(result.rowCount, 2); assert.equal(result.sourceFormat, 'xlsx'); assert.equal(result.worksheetName, 'Sheet1'); assert.equal(result.headerRow, 2); assert.equal(result.canonicalTsv, [ CANONICAL_HEADER, '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注', '2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t' ].join('\n')); }); test('an absent phone header produces blank phones without guessing unheaded values', async () => { const baseline = await normalizeSynthetic(await syntheticWorkbook()); const expected = baseline.canonicalTsv.split('\n').map((line, index) => { if (index === 0) return line; const fields = line.split('\t'); fields[11] = ''; return fields.join('\t'); }).join('\n'); for (const removeColumn of [false, true]) { const result = await normalizeSynthetic(await syntheticWorkbook(workbook => { const sheet = workbook.getWorksheet('Sheet1')!; if (removeColumn) sheet.spliceColumns(13, 1); else sheet.getCell('M2').value = null; // The original number below remains unheaded. })); assert.equal(result.canonicalTsv, expected); assert.equal(result.rowCount, 2); const parsed = await parseProgramInput({ rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-18\n名单内容:\n${result.canonicalTsv}`, receivedAt: new Date('2026-09-16T00:00:00Z') }); assert.equal(parsed.status, 'agent_parse_passed'); } }); test('phone-free rosters support reordered alias headers, merged remarks and XLS conversion', async () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('无电话模板'); sheet.mergeCells('B1:K1'); sheet.getCell('B1').value = '脱敏团队信息'; sheet.addRow(['序号', '姓名', '英文姓名', '护照号码', '性别', '出生日期', '年龄', '出生地点', '签发地', '签发日期', '有效期至', '备注']); for (let sequence = 1; sequence <= 8; sequence++) { sheet.addRow([sequence, '测试游客', 'TEST', `P0000000${sequence}`, '女', '1990-01-02', 36, '测试地', '测试地', '2025-01-02', '2035-01-01', '普通备注']); } sheet.mergeCells('L3:L10'); sheet.getCell('M10').value = 13800000000; // Extra unheaded content must not become a phone. const xlsx = Buffer.from(await workbook.xlsx.writeBuffer()); const expected = await normalizeSynthetic(xlsx); assert.equal(expected.rowCount, 8); for (const row of expected.canonicalTsv.split('\n').slice(1)) { assert.equal(row.split('\t').length, 13); assert.deepEqual(row.split('\t').slice(11), ['', '普通备注']); } assert.doesNotMatch(expected.canonicalTsv, /13800000000/); const legacy = await normalizePassengerRosterWorkbook({ content: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel' }, { convertLegacyXls: async () => xlsx }); assert.equal(legacy.canonicalTsv, expected.canonicalTsv); }); test('phone is the only optional semantic and duplicate phone headers stay blocked', async () => { for (const column of [1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 14]) { const content = await syntheticWorkbook(workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('M2').value = null; sheet.getCell(2, column).value = null; }); await expectRosterError(() => normalizeSynthetic(content), 'roster_workbook_header_not_found'); } await expectRosterError(() => syntheticWorkbook(workbook => { workbook.getWorksheet('Sheet1')!.getCell('O2').value = '电话'; }).then(normalizeSynthetic), 'roster_workbook_header_not_found'); }); test('present phone columns retain formula, value, hidden-data and row checks', async () => { for (const [value, code] of [ [{ formula: '1+1', result: 2 }, 'roster_workbook_formula_not_allowed'], [{ error: '#VALUE!' }, 'roster_workbook_invalid_cell'], [1.5, 'roster_workbook_invalid_identifier'] ] as const) { await expectRosterError(() => syntheticWorkbook(workbook => { workbook.getWorksheet('Sheet1')!.getCell('M3').value = value; }).then(normalizeSynthetic), code, { row: 3, column: 13 }); } await expectRosterError(() => syntheticWorkbook(workbook => { workbook.getWorksheet('Sheet1')!.getColumn(13).hidden = true; }).then(normalizeSynthetic), 'roster_workbook_hidden_data'); await expectRosterError(() => syntheticWorkbook(workbook => { workbook.getWorksheet('Sheet1')!.getCell('M5').value = '13800000000'; }).then(normalizeSynthetic), 'roster_workbook_invalid_sequence', { row: 5, column: 1 }); }); test('a marked leader still requires a phone when the source phone column is absent', async () => { const result = await normalizeSynthetic(await syntheticWorkbook(workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('M2').value = null; sheet.getCell('N3').value = '领队'; })); const parsed = await parseProgramInput({ rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-18\n名单内容:\n${result.canonicalTsv}`, receivedAt: new Date('2026-09-16T00:00:00Z') }); assert.notEqual(parsed.status, 'agent_parse_passed'); assert.match(JSON.stringify(parsed), /姓名和电话都必须填写/); }); test('a merged title error is skipped, while header and passenger errors stay blocked', async () => { const baseline = await normalizeSynthetic(await syntheticWorkbook()); const titleError = await normalizeSynthetic(await syntheticWorkbook(workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.mergeCells('F1:N1'); sheet.getCell('F1').value = { error: '#VALUE!' }; })); assert.equal(titleError.canonicalTsv, baseline.canonicalTsv); for (const [address, code, row, column] of [ ['F2', 'roster_workbook_header_not_found', undefined, undefined], ['F3', 'roster_workbook_invalid_cell', 3, 6], ['N4', 'roster_workbook_invalid_cell', 4, 14] ] as const) { const content = await syntheticWorkbook(workbook => { workbook.getWorksheet('Sheet1')!.getCell(address).value = { error: '#VALUE!' }; }); await expectRosterError(() => normalizeSynthetic(content), code, { row, column }); } }); test('same-row identity templates support 18 and 15 digit sources without exporting them', async () => { for (const legacy of [false, true]) { const normalized = await normalizeSynthetic(await syntheticWorkbook(identityFormulas, workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('E2').value = '身份证'; if (legacy) { sheet.getCell('E3').value = 111111900102002; sheet.getCell('D3').value = { formula: genderTemplate('E3'), result: '女' }; } })); const row = normalized.canonicalTsv.split('\n')[1]!.split('\t'); assert.deepEqual(row.slice(3, 5), [legacy ? '女' : '男', '1990-01-02']); assert.equal(row[6], '护照'); assert.equal(row[7], '123456789'); assert.doesNotMatch(normalized.canonicalTsv, /111111|身份证/); } }); test('identity references follow reordered columns, shared formulas and the workbook date system', async () => { const content = await syntheticWorkbook(identityFormulas, workbook => { const sheet = workbook.getWorksheet('Sheet1')!; for (const rowNumber of [2, 3, 4]) { const row = sheet.getRow(rowNumber); const values = REORDERED_SOURCE_INDEXES.map(index => row.getCell(index + 1).value); row.values = values; } workbook.properties.date1904 = true; const birthSerial = (Date.UTC(1990, 0, 2) - Date.UTC(1904, 0, 1)) / 86_400_000; sheet.getCell('I3').value = { formula: genderTemplate('$F3').toLowerCase(), result: '男' }; sheet.getCell('K3').value = { formula: birthTemplate('F3'), result: birthSerial }; sheet.getCell('J3').value = new Date(Date.UTC(2035, 0, 1)); sheet.getCell('F4').value = SYNTHETIC_ID; sheet.getCell('I4').value = { sharedFormula: 'I3', result: '男' }; sheet.getCell('K4').value = { sharedFormula: 'K3', result: birthSerial }; }); const result = await normalizeSynthetic(content); for (const row of result.canonicalTsv.split('\n').slice(1)) { assert.deepEqual(row.split('\t').slice(3, 5), ['男', '1990-01-02']); } }); test('identity formulas reject missing, stale or error results at the formula cell', async () => { for (const [address, value, code, column] of [ ['D3', { formula: genderTemplate('E3') }, 'roster_workbook_formula_result_missing', 4], ['F3', { formula: birthTemplate('E3') }, 'roster_workbook_formula_result_missing', 6], ['D3', { formula: genderTemplate('E3'), result: '女' }, 'roster_workbook_formula_result_mismatch', 4], ['F3', { formula: birthTemplate('E3'), result: '1990-01-03' }, 'roster_workbook_formula_result_mismatch', 6], ['F3', { formula: birthTemplate('E3'), result: { error: '#VALUE!' } }, 'roster_workbook_invalid_cell', 6] ] as const) { const content = await syntheticWorkbook(identityFormulas, workbook => { workbook.getWorksheet('Sheet1')!.getCell(address).value = value; }); await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column }); } }); test('identity dependencies must be unambiguous same-row template references', async () => { const variants: Array<[WorkbookMutation, string, number]> = [ [workbook => { workbook.getWorksheet('Sheet1')!.getCell('E2').value = '普通额外列'; }, 'roster_workbook_formula_not_allowed', 4], [workbook => { workbook.getWorksheet('Sheet1')!.getCell('O2').value = '身份证'; }, 'roster_workbook_formula_not_allowed', 4], [workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: genderTemplate('E4'), result: '男' }; }, 'roster_workbook_formula_not_allowed', 4], [workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: '"男"', result: '男' }; }, 'roster_workbook_formula_not_allowed', 4], [workbook => { workbook.getWorksheet('Sheet1')!.getCell('F3').value = { formula: birthTemplate('E3').replaceAll(';', ','), result: '1990-01-02' }; }, 'roster_workbook_formula_not_allowed', 6], [workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: genderTemplate("'[external.xlsx]Sheet1'!E3"), result: '男' }; }, 'roster_workbook_unsafe_content', 4] ]; for (const [mutation, code, column] of variants) { const content = await syntheticWorkbook(identityFormulas, mutation); await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column }); } }); test('invalid identity sources cannot validate a cached gender or birth date', async () => { for (const [value, code] of [ ['', 'roster_workbook_invalid_identifier'], ['invalid source', 'roster_workbook_invalid_identifier'], [` ${SYNTHETIC_ID}`, 'roster_workbook_invalid_identifier'], [`${SYNTHETIC_ID}\n`, 'roster_workbook_invalid_identifier'], [`111111\t${SYNTHETIC_ID.slice(6)}`, 'roster_workbook_invalid_identifier'], [111111199001020010, 'roster_workbook_invalid_identifier'], ['11111119900230001X', 'roster_workbook_invalid_date'], [{ formula: '"11111119900102001X"', result: SYNTHETIC_ID }, 'roster_workbook_formula_not_allowed'] ] as const) { const content = await syntheticWorkbook(identityFormulas, workbook => { workbook.getWorksheet('Sheet1')!.getCell('E3').value = value; }); const error = await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column: 5 }); assert.doesNotMatch(JSON.stringify(error), /111111|invalid source/); } }); test('populated identity formula rows are not discarded as empty trailing templates', async () => { const content = await syntheticWorkbook(identityFormulas, workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getRow(5).values = [3, '', '', { formula: genderTemplate('E5'), result: '男' }, SYNTHETIC_ID, { formula: birthTemplate('E5'), result: '1990-01-02' }]; }); const result = await normalizeSynthetic(content); assert.equal(result.rowCount, 3); // Retain the partial row for downstream import/supplement validation. assert.deepEqual(result.canonicalTsv.split('\n')[3]!.split('\t').slice(0, 5), ['3', '', '', '男', '1990-01-02']); }); test('a 22-person template passes normalization and program parsing with the last row as leader', async () => { const content = await syntheticWorkbook(identityFormulas, workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.mergeCells('F1:N1'); sheet.getCell('F1').value = { error: '#VALUE!' }; const values = sheet.getRow(3).values; for (let index = 1; index <= 22; index++) { const rowNumber = index + 2; const row = sheet.getRow(rowNumber); row.values = values; row.getCell('A').value = index; row.getCell('I').value = `P${String(index).padStart(8, '0')}`; row.getCell('D').value = { formula: genderTemplate(`E${rowNumber}`), result: '男' }; row.getCell('F').value = { formula: birthTemplate(`E${rowNumber}`), result: '1990-01-02' }; row.getCell('K').value = { formula: `EDATE(L${rowNumber},-10*12)+1`, result: '2025-01-02' }; row.getCell('L').value = '2035-01-01'; row.getCell('M').value = index === 22 ? '13800000000' : ''; row.getCell('N').value = index === 22 ? '领队' : ''; } }); const result = await normalizeSynthetic(content); assert.equal(result.rowCount, 22); assert.doesNotMatch(result.canonicalTsv, /111111|身份证|#VALUE!/); const parsed = await parseProgramInput({ rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-21\n名单内容:\n${result.canonicalTsv}`, receivedAt: new Date('2026-09-15T00:00:00Z') }); assert.equal(parsed.status, 'agent_parse_passed'); const data = parsed.operation?.data as { passenger_list?: { leader_contact?: { sequence?: number } } }; assert.equal(data?.passenger_list?.leader_contact?.sequence, 22); }); test('the ERP field row is auto-detected and source columns may be reordered', async () => { const valid = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('N1').value = '任意团信息'; }); const normalized = await normalizePassengerRosterWorkbook({ content: valid, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); assert.equal(normalized.headerRow, 2); assert.equal(normalized.rowCount, 2); const reordered = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; for (const rowNumber of [2, 3, 4]) { const row = sheet.getRow(rowNumber); const values = REORDERED_SOURCE_INDEXES.map((index) => row.getCell(index + 1).value); row.values = values; } sheet.getCell('G3').value = { formula: 'DATEDIF(K3,TODAY(),"Y")', result: 36 }; sheet.getCell('J3').value = { formula: 'EDATE(E3,10*12)-1', result: new Date(Date.UTC(2035, 0, 1)) }; sheet.getCell('G4').value = { formula: 'DATEDIF(K4,TODAY(),"Y")', result: 26 }; sheet.getCell('J4').value = { formula: 'EDATE(E4,10*12)-1', result: 49310 }; }); const reorderedResult = await normalizePassengerRosterWorkbook({ content: reordered, fileName: 'reordered-roster.xlsx', contentType: 'application/octet-stream' }); assert.equal(reorderedResult.headerRow, 2); assert.equal(reorderedResult.canonicalTsv, [ CANONICAL_HEADER, '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注', '2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t' ].join('\n')); const shifted = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.insertRow(2, ['额外说明']); sheet.getCell('G4').value = { formula: 'DATEDIF(F4,TODAY(),"Y")', result: 36 }; sheet.getCell('L4').value = { formula: 'EDATE(K4,10*12)-1', result: new Date(Date.UTC(2035, 0, 1)) }; sheet.getCell('G5').value = { formula: 'DATEDIF(F5,TODAY(),"Y")', result: 26 }; sheet.getCell('L5').value = { formula: 'EDATE(K5,10*12)-1', result: 49310 }; }); const shiftedResult = await normalizePassengerRosterWorkbook({ content: shifted, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); assert.equal(shiftedResult.headerRow, 3); assert.equal(shiftedResult.rowCount, 2); const erpHeaderOnFirstRow = await normalizePassengerRosterWorkbook({ content: await erpHeaderWorkbook(), fileName: 'erp-roster.xlsx', contentType: 'application/octet-stream' }); assert.equal(erpHeaderOnFirstRow.headerRow, 1); assert.equal(erpHeaderOnFirstRow.canonicalTsv, [ CANONICAL_HEADER, '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注' ].join('\n')); }); test('the approved attachment aliases and reverse issue-date formula are normalized', async () => { const attachmentVariant = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; for (const rowNumber of [2, 3, 4]) { const row = sheet.getRow(rowNumber); const values = ATTACHMENT_VARIANT_INDEXES.map((index) => row.getCell(index + 1).value); row.values = values; } sheet.getCell('N2').value = '身份证'; sheet.getCell('F3').value = 36; sheet.getCell('J3').value = { formula: 'EDATE(K3,-10*12)+1', result: new Date(Date.UTC(2025, 0, 2)) }; sheet.getCell('K3').value = new Date(Date.UTC(2035, 0, 1)); sheet.getCell('F4').value = 26; sheet.getCell('J4').value = { formula: 'EDATE(K4,-10*12)+1', result: 45659 }; sheet.getCell('K4').value = 49310; }); const result = await normalizePassengerRosterWorkbook({ content: attachmentVariant, fileName: 'attachment-variant.xlsx', contentType: 'application/octet-stream' }); assert.equal(result.headerRow, 2); assert.equal(result.canonicalTsv, [ CANONICAL_HEADER, '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注', '2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t' ].join('\n')); }); test('birthplace and expiry aliases preserve the canonical passenger fields and values', async () => { const normalize = (content: Buffer) => normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); const original = await normalize(await syntheticWorkbook()); const aliased = await normalize(await syntheticWorkbook(workbook => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('H2').value = '出生地点'; sheet.getCell('L2').value = '有效期至'; })); assert.equal(aliased.headerRow, original.headerRow); assert.equal(aliased.rowCount, original.rowCount); assert.equal(aliased.canonicalTsv, original.canonicalTsv); }); test('birthplace and expiry aliases cannot silently select duplicate semantic columns', async () => { for (const [column, alias] of [['H2', '出生地点'], ['L2', '有效期至']] as const) { for (const duplicate of ['canonical', 'alias'] as const) { const content = await syntheticWorkbook(workbook => { const sheet = workbook.getWorksheet('Sheet1')!; const original = sheet.getCell(column).value; sheet.getCell(column).value = alias; sheet.getCell('O2').value = duplicate === 'canonical' ? original : alias; sheet.getCell('O3').value = '合成冲突值'; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_header_not_found' ); } } }); test('source header order may vary, but every required label must appear exactly once', async () => { const missingHeader = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('N2').value = '备注说明'; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: missingHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_header_not_found' ); const duplicateHeader = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('N2').value = '姓名'; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: duplicateHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_header_not_found' ); const ignoredExtraData = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('O2').value = '酒店偏好'; sheet.getCell('O3').value = 'SYNTHETIC-EXTRA-SHOULD-NOT-LEAK'; sheet.getCell('P3').value = { formula: '1+1' }; sheet.getCell('Q6').value = '仅额外列尾注'; }); const ignoredExtraResult = await normalizePassengerRosterWorkbook({ content: ignoredExtraData, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); assert.equal(ignoredExtraResult.rowCount, 2); assert.doesNotMatch(ignoredExtraResult.canonicalTsv, /SYNTHETIC-EXTRA-SHOULD-NOT-LEAK|酒店偏好|仅额外列尾注/); }); test('legacy XLS is accepted only through an injected fail-closed converter', async () => { const xlsx = await syntheticWorkbook(); const legacy = Buffer.concat([ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), Buffer.from('synthetic legacy workbook') ]); let conversions = 0; const result = await normalizePassengerRosterWorkbook({ content: legacy, fileName: 'synthetic-roster.xls', contentType: 'application/vnd.ms-excel' }, { convertLegacyXls: async (input) => { conversions += 1; assert.equal(input.content, legacy); return xlsx; } }); assert.equal(conversions, 1); assert.equal(result.sourceFormat, 'xls'); assert.equal(result.rowCount, 2); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: legacy, fileName: 'synthetic-roster.xls', contentType: 'application/vnd.ms-excel' }), 'roster_workbook_converter_unavailable' ); }); test('identity cards and other non-import columns are ignored without leaking values', async () => { const secret = 'SYNTHETIC-ID-SHOULD-NOT-LEAK'; const content = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('E3').value = secret; sheet.getColumn(5).hidden = true; }); const result = await normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); assert.equal(result.rowCount, 2); assert.doesNotMatch(result.canonicalTsv, new RegExp(secret)); }); test('source document type is ignored and canonical ERP document type remains passport', async () => { const sourceType = await erpHeaderWorkbook((workbook) => { workbook.getWorksheet('ERP名单')!.getCell('G2').value = '身份证'; }); const result = await normalizePassengerRosterWorkbook({ content: sourceType, fileName: 'erp-roster.xlsx', contentType: 'application/octet-stream' }); assert.equal(result.rowCount, 1); assert.equal(result.canonicalTsv.split('\n')[1]!.split('\t')[6], '护照'); assert.doesNotMatch(result.canonicalTsv, /身份证/); }); test('sequence numbers must be positive and unique', async () => { const duplicate = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('A4').value = 1; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: duplicate, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_duplicate_sequence', { row: 4, column: 1 } ); const decimal = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('A3').value = 1.5; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: decimal, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_invalid_sequence', { row: 3, column: 1 } ); const overTechnicalLimit = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('A3').value = 5001; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: overTechnicalLimit, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_sequence_limit', { row: 3, column: 1 } ); }); test('unsafe formulas stay blocked while ignored columns do not affect selected fields', async () => { const networkFormula = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('G3').value = { formula: 'WEBSERVICE("https://invalid.example")', result: '36' }; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: networkFormula, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_unsafe_content', { row: 3, column: 7 } ); const crossRowFormula = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('L3').value = { formula: 'EDATE(K4,10*12)-1', result: new Date(Date.UTC(2035, 0, 1)) }; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: crossRowFormula, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_formula_not_allowed', { row: 3, column: 12 } ); const ignoredAgeWithoutResult = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('G3').value = { formula: 'DATEDIF(F3,TODAY(),"Y")' }; }); const ignoredAgeResult = await normalizePassengerRosterWorkbook({ content: ignoredAgeWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }); assert.equal(ignoredAgeResult.rowCount, 2); const expiryWithoutResult = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('L3').value = { formula: 'EDATE(K3,10*12)-1' }; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: expiryWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_formula_result_missing', { row: 3, column: 12 } ); const issueDateWithoutResult = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('K3').value = { formula: 'EDATE(L3,-10*12)+1' }; sheet.getCell('L3').value = new Date(Date.UTC(2035, 0, 1)); }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: issueDateWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_formula_result_missing', { row: 3, column: 11 } ); }); test('macros, external relationships and ambiguous sheets fail closed', async () => { const xlsx = await syntheticWorkbook(); const zip = await JSZip.loadAsync(xlsx); zip.file('xl/vbaProject.bin', Buffer.from('synthetic macro')); const macroWorkbook = await zip.generateAsync({ type: 'nodebuffer' }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: macroWorkbook, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_unsafe_content' ); const relationshipZip = await JSZip.loadAsync(xlsx); const relationshipPath = 'xl/_rels/workbook.xml.rels'; const relationships = await relationshipZip.file(relationshipPath)!.async('string'); relationshipZip.file( relationshipPath, relationships.replace( '', '' ) ); const externalRelationship = await relationshipZip.generateAsync({ type: 'nodebuffer' }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: externalRelationship, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_unsafe_content' ); const externalFormula = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('L3').value = { formula: "'[external.xlsx]Sheet1'!A1", result: new Date(Date.UTC(2035, 0, 1)) }; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: externalFormula, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_unsafe_content' ); const multipleSheets = await syntheticWorkbook((workbook) => { workbook.addWorksheet('Extra'); }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: multipleSheets, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_ambiguous_sheet' ); }); test('hidden non-empty rows or columns and duplicate headers fail closed', async () => { const hiddenRow = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getRow(4).hidden = true; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: hiddenRow, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_hidden_data', { row: 4 } ); const hiddenColumn = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getColumn(14).hidden = true; }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: hiddenColumn, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_hidden_data', { column: 14 } ); const duplicateHeaders = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.addRow([...SOURCE_HEADERS]); }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: duplicateHeaders, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), 'roster_workbook_ambiguous_header' ); }); test('format, MIME, conversion output and technical limits are validated before parsing', async () => { const xlsx = await syntheticWorkbook(); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: xlsx, fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel' }), 'roster_workbook_format_mismatch' ); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: xlsx, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }, { maxInputBytes: 8 }), 'roster_workbook_too_large' ); const legacy = Buffer.concat([ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), Buffer.from('synthetic legacy workbook') ]); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: legacy, fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel' }, { convertLegacyXls: async () => Buffer.from('not xlsx') }), 'roster_workbook_conversion_failed' ); const conversionFailure = await expectRosterError( () => normalizePassengerRosterWorkbook({ content: legacy, fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel' }, { convertLegacyXls: async () => { throw new PassengerRosterWorkbookError('roster_workbook_conversion_failed', { conversionErrorCode: 'conversion_output_missing' }); } }), 'roster_workbook_conversion_failed' ); assert.equal(conversionFailure.conversionErrorCode, 'conversion_output_missing'); const macroLegacy = Buffer.concat([legacy, Buffer.from('_VBA_PROJECT_CUR', 'utf16le')]); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: macroLegacy, fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel' }, { convertLegacyXls: async () => xlsx }), 'roster_workbook_unsafe_content' ); const tooManyRows = await syntheticWorkbook((workbook) => { const sheet = workbook.getWorksheet('Sheet1')!; for (let sequence = 3; sequence <= 5001; sequence += 1) { sheet.addRow([ sequence, `测试${sequence}`, `TEST,${sequence}`, '男', '', '2000-01-01', '', '测试地', `P${String(sequence).padStart(8, '0')}`, '测试签发地', '2025-01-01', '2035-01-01', '', '' ]); } }); await expectRosterError( () => normalizePassengerRosterWorkbook({ content: tooManyRows, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }, { maxDataRows: 6000 }), 'roster_workbook_row_limit' ); });