576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import ExcelJS from 'exceljs';
|
||
import JSZip from 'jszip';
|
||
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>;
|
||
|
||
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('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('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'
|
||
);
|
||
});
|