Files
LWLT-AIBOT/control-plane/test/passenger-roster-workbook.test.ts
2026-09-16 17:11:16 +08:00

871 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>;
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<Buffer> {
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<Buffer> {
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<unknown>,
code: string,
location?: { row?: number; column?: number }
): Promise<PassengerRosterWorkbookError> {
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(
'</Relationships>',
'<Relationship Id="syntheticExternal" Type="urn:synthetic" Target="https://invalid.example" TargetMode="External"/></Relationships>'
)
);
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'
);
});