Files
LWLT-AIBOT/control-plane/test/document-converter.test.ts
2026-09-11 16:18:44 +08:00

234 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { loadConfig } from '../src/config.js';
import { convertDocumentToPdf, convertDocumentToXlsx } from '../src/document-converter.js';
function testConfig(converterPath: string, fontConfigFile?: string) {
return loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 21).toString('base64'),
DOCUMENT_CONVERTER_PATH: converterPath,
...(fontConfigFile ? { DOCUMENT_FONTCONFIG_FILE: fontConfigFile } : {})
});
}
test('PDF sources pass through with PDF metadata', async () => {
const content = Buffer.from('%PDF-1.7\nsource');
const result = await convertDocumentToPdf(testConfig('/path/that/is/not/used'), {
fileName: 'team.doc',
contentType: 'application/msword',
content,
maxBytes: 10_000
});
assert.equal(result.fileName, 'team.pdf');
assert.equal(result.contentType, 'application/pdf');
assert.equal(result.converted, true);
assert.equal(result.conversion_status, 'already_pdf');
assert.deepEqual(result.content, content);
});
test('conversion failure falls back to the source file', async () => {
const content = Buffer.from('not a valid office document');
const result = await convertDocumentToPdf(testConfig('/path/that/does/not/exist'), {
fileName: 'team.doc',
contentType: 'application/msword',
content,
maxBytes: 10_000
});
assert.equal(result.fileName, 'team.doc');
assert.equal(result.contentType, 'application/msword');
assert.equal(result.converted, false);
assert.equal(result.conversion_status, 'source_fallback');
assert.equal(result.conversion_error_code, 'converter_unavailable');
assert.deepEqual(result.content, content);
});
test('PDF conversion selects Writer HTML import only for HTML documents', {
skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false
}, async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-converter-import-test-'));
const converterPath = join(temporaryDirectory, 'fake-soffice.sh');
await writeFile(converterPath, [
'#!/bin/sh',
'outdir=""',
'next_is_outdir=0',
'for arg in "$@"; do',
' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi',
' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; fi',
'done',
'printf "%s\\n" "%PDF-1.7" "$@" > "$outdir/team.pdf"'
].join('\n'), { mode: 0o700 });
await chmod(converterPath, 0o700);
const cases = [
{ name: 'ERP Word HTML with vendor MIME', fileName: 'team.doc', contentType: 'application/vnd.ms-word; Charset=UTF-8', content: '\uFEFF\r\n<html><body><p>确认书标题</p><p>正文</p></body></html>', html: true },
{ name: 'HTML doctype in a Word file', fileName: 'team.doc', contentType: 'application/msword', content: '<!DOCTYPE HTML><HTML><BODY>确认书</BODY></HTML>', html: true },
{ name: 'legacy body-only Word HTML', fileName: 'team.doc', contentType: 'application/msword', content: '<body><p>确认书</p></body>', html: true },
{ name: 'HTML extension', fileName: 'team.html', contentType: 'application/octet-stream', content: '<p>确认书</p>', html: true },
{ name: 'HTML MIME without extension', fileName: 'team', contentType: 'text/html; charset=utf-8', content: '<p>确认书</p>', html: true },
{ name: 'binary Word', fileName: 'team.doc', contentType: 'application/msword', content: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), html: false },
{ name: 'DOCX package', fileName: 'team.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', content: Buffer.from([0x50, 0x4b, 0x03, 0x04]), html: false },
{ name: 'RTF', fileName: 'team.rtf', contentType: 'application/rtf', content: '{\\rtf1 Confirmation}', html: false }
];
try {
for (const item of cases) {
await t.test(item.name, async () => {
const result = await convertDocumentToPdf(testConfig(converterPath), {
fileName: item.fileName,
contentType: item.contentType,
content: Buffer.from(item.content),
maxBytes: 10_000
});
assert.equal(result.converted, true);
const args = result.content.toString('utf8').split('\n');
assert.deepEqual(
args.filter((arg) => arg.startsWith('--infilter')),
item.html ? ['--infilter=HTML (StarWriter)'] : []
);
assert.ok(args.includes('pdf:writer_pdf_Export'));
});
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
test('a valid converter output replaces the source with a PDF', {
skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false
}, async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-converter-test-'));
const converterPath = join(temporaryDirectory, 'fake-soffice.sh');
const fontConfigFile = join(temporaryDirectory, 'fonts.conf');
await writeFile(fontConfigFile, '<fontconfig/>');
await writeFile(converterPath, [
'#!/bin/sh',
'outdir=""',
'convert_to=""',
'next_is_outdir=0',
'next_is_convert_to=0',
'for arg in "$@"; do',
' if [ "$next_is_convert_to" = "1" ]; then convert_to="$arg"; next_is_convert_to=0; continue; fi',
' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi',
' if [ "$arg" = "--convert-to" ]; then next_is_convert_to=1; fi',
' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; fi',
'done',
'[ "$convert_to" = "pdf:writer_pdf_Export" ] || exit 41',
'[ "$FONTCONFIG_FILE" = "' + fontConfigFile + '" ] || exit 42',
'printf "%s" "%PDF-1.7\\nconverted" > "$outdir/team.pdf"'
].join('\n'), { mode: 0o700 });
await chmod(converterPath, 0o700);
try {
const result = await convertDocumentToPdf(testConfig(converterPath, fontConfigFile), {
fileName: 'team.docx',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
content: Buffer.from('source'),
maxBytes: 10_000
});
assert.equal(result.fileName, 'team.pdf');
assert.equal(result.contentType, 'application/pdf');
assert.equal(result.converted, true);
assert.equal(result.conversion_status, 'converted');
assert.match(result.content.toString(), /^%PDF-1\.7/);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
test('HTML-in-XLS visitor sources become real XLSX workbooks', {
skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false
}, async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-converter-xlsx-test-'));
const converterPath = join(temporaryDirectory, 'fake-soffice.sh');
await writeFile(converterPath, [
'#!/bin/sh',
'outdir=""',
'convert_to=""',
'input_path=""',
'next_is_outdir=0',
'next_is_convert_to=0',
'for arg in "$@"; do',
' if [ "$next_is_convert_to" = "1" ]; then convert_to="$arg"; next_is_convert_to=0; continue; fi',
' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi',
' if [ "$arg" = "--convert-to" ]; then next_is_convert_to=1; continue; fi',
' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; continue; fi',
' case "$arg" in *.xls) input_path="$arg";; esac',
'done',
'[ "$convert_to" = "xlsx:Calc MS Excel 2007 XML" ] || exit 41',
'case "$input_path" in *.xls) ;; *) exit 42;; esac',
'printf "PK\\003\\004fake-xlsx" > "$outdir/Visitor.xlsx"'
].join('\n'), { mode: 0o700 });
await chmod(converterPath, 0o700);
try {
const content = Buffer.from('<!doctype html><html><body><table><tr><td>游客</td></tr></table></body></html>', 'utf8');
const result = await convertDocumentToXlsx(testConfig(converterPath), {
fileName: 'Visitor.xls',
contentType: 'application/vnd.ms-excel; Charset=UTF-8',
content,
maxBytes: 10_000
});
assert.equal(result.fileName, 'Visitor.xlsx');
assert.equal(result.contentType, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
assert.equal(result.converted, true);
assert.equal(result.conversion_status, 'converted_xlsx');
assert.deepEqual(result.content.subarray(0, 4), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
test('visitor XLSX conversion failure remains a source fallback', async () => {
const content = Buffer.from('<html><body>游客</body></html>', 'utf8');
const result = await convertDocumentToXlsx(testConfig('/path/that/does/not/exist'), {
fileName: 'Visitor.xls',
contentType: 'application/vnd.ms-excel',
content,
maxBytes: 10_000
});
assert.equal(result.fileName, 'Visitor.xls');
assert.equal(result.converted, false);
assert.equal(result.conversion_status, 'source_fallback');
assert.equal(result.conversion_error_code, 'converter_unavailable');
assert.deepEqual(result.content, content);
});
test('wide Word HTML tables are scaled to the printable width before conversion', {
skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false
}, async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-converter-html-test-'));
const converterPath = join(temporaryDirectory, 'fake-soffice.sh');
await writeFile(converterPath, [
'#!/bin/sh',
'outdir=""',
'input_path=""',
'next_is_outdir=0',
'for arg in "$@"; do',
' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi',
' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; continue; fi',
' case "$arg" in *.doc|*.docx|*.html) input_path="$arg";; esac',
'done',
'grep -q "width=733" "$input_path" || exit 43',
'grep -q "width=\\\"154\\\"" "$input_path" || exit 44',
'grep -q "width: 550pt" "$input_path" || exit 45',
'printf "%s" "%PDF-1.7\\nconverted" > "$outdir/team.pdf"'
].join('\n'), { mode: 0o700 });
await chmod(converterPath, 0o700);
try {
const result = await convertDocumentToPdf(testConfig(converterPath), {
fileName: 'team.doc',
contentType: 'application/msword',
content: Buffer.from(
'<html><head><style>table { width: 760px; } .inner { width: 570pt; }</style></head>'
+ '<body><table width=760><tr><td width="160" class="inner">内容</td></tr></table></body></html>',
'utf8'
),
maxBytes: 10_000
});
assert.equal(result.converted, true);
assert.equal(result.conversion_status, 'converted');
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});