update
This commit is contained in:
461
test/suite/einvoice_edge-cases/test.edge-01.empty-files.ts
Normal file
461
test/suite/einvoice_edge-cases/test.edge-01.empty-files.ts
Normal file
@ -0,0 +1,461 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-01: Empty Invoice Files');
|
||||
|
||||
tap.test('EDGE-01: Empty Invoice Files - should handle empty and near-empty files gracefully', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Completely empty file
|
||||
const completelyEmpty = await performanceTracker.measureAsync(
|
||||
'completely-empty-file',
|
||||
async () => {
|
||||
const emptyContent = '';
|
||||
|
||||
try {
|
||||
const result = await einvoice.parseDocument(emptyContent);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
parsed: !!result,
|
||||
error: null,
|
||||
contentLength: emptyContent.length
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
handled: true,
|
||||
parsed: false,
|
||||
error: error.message,
|
||||
errorType: error.constructor.name
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(completelyEmpty.handled, 'Completely empty file was handled');
|
||||
t.notOk(completelyEmpty.parsed, 'Empty file was not parsed as valid');
|
||||
|
||||
// Test 2: Only whitespace
|
||||
const onlyWhitespace = await performanceTracker.measureAsync(
|
||||
'only-whitespace',
|
||||
async () => {
|
||||
const whitespaceVariants = [
|
||||
' ',
|
||||
'\n',
|
||||
'\r\n',
|
||||
'\t',
|
||||
' \n\n\t\t \r\n ',
|
||||
' '.repeat(1000)
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const content of whitespaceVariants) {
|
||||
try {
|
||||
const result = await einvoice.parseDocument(content);
|
||||
results.push({
|
||||
content: content.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'),
|
||||
length: content.length,
|
||||
parsed: !!result,
|
||||
error: null
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
content: content.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'),
|
||||
length: content.length,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
onlyWhitespace.forEach(result => {
|
||||
t.notOk(result.parsed, `Whitespace-only content not parsed: "${result.content}"`);
|
||||
});
|
||||
|
||||
// Test 3: Empty XML structure
|
||||
const emptyXMLStructure = await performanceTracker.measureAsync(
|
||||
'empty-xml-structure',
|
||||
async () => {
|
||||
const emptyStructures = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n',
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Invoice></Invoice>',
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Invoice/>',
|
||||
'<Invoice></Invoice>',
|
||||
'<Invoice/>'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const xml of emptyStructures) {
|
||||
try {
|
||||
const result = await einvoice.parseDocument(xml);
|
||||
const validation = await einvoice.validate(result);
|
||||
|
||||
results.push({
|
||||
xml: xml.substring(0, 50),
|
||||
parsed: true,
|
||||
valid: validation?.isValid || false,
|
||||
hasContent: !!result && Object.keys(result).length > 0
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
xml: xml.substring(0, 50),
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
emptyXMLStructure.forEach(result => {
|
||||
if (result.parsed) {
|
||||
t.notOk(result.valid, 'Empty XML structure is not valid invoice');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Empty required fields
|
||||
const emptyRequiredFields = await performanceTracker.measureAsync(
|
||||
'empty-required-fields',
|
||||
async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'empty-id',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID></ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'whitespace-id',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID> </ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'empty-amount',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>INV-001</ID>
|
||||
<TotalAmount></TotalAmount>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(testCase.xml);
|
||||
const validation = await einvoice.validate(parsed);
|
||||
|
||||
results.push({
|
||||
name: testCase.name,
|
||||
parsed: true,
|
||||
valid: validation?.isValid || false,
|
||||
errors: validation?.errors || []
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: testCase.name,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
emptyRequiredFields.forEach(result => {
|
||||
t.notOk(result.valid, `${result.name} is not valid`);
|
||||
});
|
||||
|
||||
// Test 5: Zero-byte file
|
||||
const zeroByteFile = await performanceTracker.measureAsync(
|
||||
'zero-byte-file',
|
||||
async () => {
|
||||
const zeroByteBuffer = Buffer.alloc(0);
|
||||
|
||||
try {
|
||||
const result = await einvoice.parseDocument(zeroByteBuffer);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
parsed: !!result,
|
||||
bufferLength: zeroByteBuffer.length
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
handled: true,
|
||||
parsed: false,
|
||||
error: error.message,
|
||||
bufferLength: zeroByteBuffer.length
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(zeroByteFile.handled, 'Zero-byte buffer was handled');
|
||||
t.equal(zeroByteFile.bufferLength, 0, 'Buffer length is zero');
|
||||
|
||||
// Test 6: Empty arrays and objects
|
||||
const emptyCollections = await performanceTracker.measureAsync(
|
||||
'empty-collections',
|
||||
async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'empty-line-items',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>INV-001</ID>
|
||||
<InvoiceLines></InvoiceLines>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'empty-tax-totals',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>INV-001</ID>
|
||||
<TaxTotal></TaxTotal>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(testCase.xml);
|
||||
|
||||
results.push({
|
||||
name: testCase.name,
|
||||
parsed: true,
|
||||
hasEmptyCollections: true,
|
||||
structure: JSON.stringify(parsed).substring(0, 100)
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: testCase.name,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
emptyCollections.forEach(result => {
|
||||
t.ok(result.parsed || result.error, `${result.name} was processed`);
|
||||
});
|
||||
|
||||
// Test 7: Empty PDF files
|
||||
const emptyPDFFiles = await performanceTracker.measureAsync(
|
||||
'empty-pdf-files',
|
||||
async () => {
|
||||
const pdfTests = [
|
||||
{
|
||||
name: 'empty-pdf-header',
|
||||
content: Buffer.from('%PDF-1.4\n%%EOF')
|
||||
},
|
||||
{
|
||||
name: 'pdf-no-content',
|
||||
content: Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\nxref\n0 1\n0000000000 65535 f\ntrailer\n<</Size 1>>\n%%EOF')
|
||||
},
|
||||
{
|
||||
name: 'zero-byte-pdf',
|
||||
content: Buffer.alloc(0)
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of pdfTests) {
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(test.content);
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
processed: true,
|
||||
hasXML: !!result?.xml,
|
||||
hasAttachments: result?.attachments?.length > 0,
|
||||
size: test.content.length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: test.name,
|
||||
processed: false,
|
||||
error: error.message,
|
||||
size: test.content.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
emptyPDFFiles.forEach(result => {
|
||||
t.ok(!result.hasXML, `${result.name} has no XML content`);
|
||||
});
|
||||
|
||||
// Test 8: Format detection on empty files
|
||||
const formatDetectionEmpty = await performanceTracker.measureAsync(
|
||||
'format-detection-empty',
|
||||
async () => {
|
||||
const emptyVariants = [
|
||||
{ content: '', name: 'empty-string' },
|
||||
{ content: ' ', name: 'space' },
|
||||
{ content: '\n', name: 'newline' },
|
||||
{ content: '<?xml?>', name: 'incomplete-xml-declaration' },
|
||||
{ content: '<', name: 'single-bracket' },
|
||||
{ content: Buffer.alloc(0), name: 'empty-buffer' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const variant of emptyVariants) {
|
||||
try {
|
||||
const format = await einvoice.detectFormat(variant.content);
|
||||
|
||||
results.push({
|
||||
name: variant.name,
|
||||
detected: !!format,
|
||||
format: format,
|
||||
confidence: format?.confidence || 0
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: variant.name,
|
||||
detected: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
formatDetectionEmpty.forEach(result => {
|
||||
t.notOk(result.detected, `Format not detected for ${result.name}`);
|
||||
});
|
||||
|
||||
// Test 9: Empty namespace handling
|
||||
const emptyNamespaces = await performanceTracker.measureAsync(
|
||||
'empty-namespace-handling',
|
||||
async () => {
|
||||
const namespaceTests = [
|
||||
{
|
||||
name: 'empty-default-namespace',
|
||||
xml: '<Invoice xmlns=""></Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'empty-prefix-namespace',
|
||||
xml: '<ns:Invoice xmlns:ns=""></ns:Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'whitespace-namespace',
|
||||
xml: '<Invoice xmlns=" "></Invoice>'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of namespaceTests) {
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(test.xml);
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
parsed: true,
|
||||
hasNamespace: !!parsed?.namespace
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: test.name,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
emptyNamespaces.forEach(result => {
|
||||
t.ok(result.parsed !== undefined, `${result.name} was processed`);
|
||||
});
|
||||
|
||||
// Test 10: Recovery from empty files
|
||||
const emptyFileRecovery = await performanceTracker.measureAsync(
|
||||
'empty-file-recovery',
|
||||
async () => {
|
||||
const recoveryTest = async () => {
|
||||
const results = {
|
||||
emptyHandled: false,
|
||||
normalAfterEmpty: false,
|
||||
batchWithEmpty: false
|
||||
};
|
||||
|
||||
// Test 1: Handle empty file
|
||||
try {
|
||||
await einvoice.parseDocument('');
|
||||
} catch (error) {
|
||||
results.emptyHandled = true;
|
||||
}
|
||||
|
||||
// Test 2: Parse normal file after empty
|
||||
try {
|
||||
const normal = await einvoice.parseDocument(
|
||||
'<?xml version="1.0"?><Invoice><ID>TEST</ID></Invoice>'
|
||||
);
|
||||
results.normalAfterEmpty = !!normal;
|
||||
} catch (error) {
|
||||
// Should not happen
|
||||
}
|
||||
|
||||
// Test 3: Batch with empty file
|
||||
try {
|
||||
const batch = await einvoice.batchProcess([
|
||||
'<?xml version="1.0"?><Invoice><ID>1</ID></Invoice>',
|
||||
'',
|
||||
'<?xml version="1.0"?><Invoice><ID>2</ID></Invoice>'
|
||||
]);
|
||||
results.batchWithEmpty = batch?.processed === 2;
|
||||
} catch (error) {
|
||||
// Batch might fail completely
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
return await recoveryTest();
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(emptyFileRecovery.normalAfterEmpty, 'Can parse normal file after empty file');
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
668
test/suite/einvoice_edge-cases/test.edge-02.gigabyte-files.ts
Normal file
668
test/suite/einvoice_edge-cases/test.edge-02.gigabyte-files.ts
Normal file
@ -0,0 +1,668 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-02: Gigabyte-Size Invoices');
|
||||
|
||||
tap.test('EDGE-02: Gigabyte-Size Invoices - should handle extremely large invoice files', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Large number of line items
|
||||
const manyLineItems = await performanceTracker.measureAsync(
|
||||
'many-line-items',
|
||||
async () => {
|
||||
// Create invoice with 100,000 line items (simulated)
|
||||
const lineItemCount = 100000;
|
||||
const chunkSize = 1000;
|
||||
|
||||
const header = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>LARGE-001</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
<InvoiceLines>`;
|
||||
|
||||
const footer = ` </InvoiceLines>
|
||||
<TotalAmount>1000000.00</TotalAmount>
|
||||
</Invoice>`;
|
||||
|
||||
// Simulate streaming parse
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
try {
|
||||
// In real implementation, would stream parse
|
||||
const mockStream = {
|
||||
header,
|
||||
lineItemCount,
|
||||
footer,
|
||||
processed: 0
|
||||
};
|
||||
|
||||
// Process in chunks
|
||||
while (mockStream.processed < lineItemCount) {
|
||||
const batchSize = Math.min(chunkSize, lineItemCount - mockStream.processed);
|
||||
|
||||
// Simulate processing chunk
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const itemNum = mockStream.processed + i;
|
||||
// Would normally append to stream: generateLineItem(itemNum)
|
||||
}
|
||||
|
||||
mockStream.processed += batchSize;
|
||||
|
||||
// Check memory usage
|
||||
const currentMemory = process.memoryUsage();
|
||||
if (currentMemory.heapUsed - startMemory.heapUsed > 500 * 1024 * 1024) {
|
||||
throw new Error('Memory limit exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
lineItems: lineItemCount,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMemory.heapUsed - startMemory.heapUsed,
|
||||
throughput: lineItemCount / ((endTime - startTime) / 1000)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
lineItems: mockStream?.processed || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(manyLineItems.success || manyLineItems.error, 'Large line item count was processed');
|
||||
|
||||
// Test 2: Large text content
|
||||
const largeTextContent = await performanceTracker.measureAsync(
|
||||
'large-text-content',
|
||||
async () => {
|
||||
// Create invoice with very large description fields
|
||||
const descriptionSize = 10 * 1024 * 1024; // 10MB per description
|
||||
const itemCount = 10;
|
||||
|
||||
const results = {
|
||||
totalSize: 0,
|
||||
processed: 0,
|
||||
memoryPeaks: []
|
||||
};
|
||||
|
||||
try {
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const largeDescription = 'A'.repeat(descriptionSize);
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>LARGE-TEXT-${i}</ID>
|
||||
<Description>${largeDescription}</Description>
|
||||
</Invoice>`;
|
||||
|
||||
const memBefore = process.memoryUsage().heapUsed;
|
||||
|
||||
// Process with streaming if available
|
||||
const processed = await einvoice.parseWithStreaming(xml);
|
||||
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
results.memoryPeaks.push(memAfter - memBefore);
|
||||
|
||||
results.totalSize += xml.length;
|
||||
results.processed++;
|
||||
|
||||
// Force GC between items if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...results,
|
||||
avgMemoryPerItem: results.memoryPeaks.reduce((a, b) => a + b, 0) / results.memoryPeaks.length
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
...results
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(largeTextContent.processed > 0, 'Large text content was processed');
|
||||
|
||||
// Test 3: Streaming vs loading entire file
|
||||
const streamingComparison = await performanceTracker.measureAsync(
|
||||
'streaming-vs-loading',
|
||||
async () => {
|
||||
const testSizes = [
|
||||
{ size: 1 * 1024 * 1024, name: '1MB' },
|
||||
{ size: 10 * 1024 * 1024, name: '10MB' },
|
||||
{ size: 100 * 1024 * 1024, name: '100MB' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of testSizes) {
|
||||
// Generate test data
|
||||
const testXML = generateLargeInvoice(test.size);
|
||||
|
||||
// Test full loading
|
||||
let fullLoadResult;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const startMem = process.memoryUsage();
|
||||
|
||||
await einvoice.parseDocument(testXML);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMem = process.memoryUsage();
|
||||
|
||||
fullLoadResult = {
|
||||
method: 'full-load',
|
||||
success: true,
|
||||
time: endTime - startTime,
|
||||
memory: endMem.heapUsed - startMem.heapUsed
|
||||
};
|
||||
} catch (error) {
|
||||
fullLoadResult = {
|
||||
method: 'full-load',
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Test streaming
|
||||
let streamResult;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const startMem = process.memoryUsage();
|
||||
|
||||
await einvoice.parseWithStreaming(testXML);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMem = process.memoryUsage();
|
||||
|
||||
streamResult = {
|
||||
method: 'streaming',
|
||||
success: true,
|
||||
time: endTime - startTime,
|
||||
memory: endMem.heapUsed - startMem.heapUsed
|
||||
};
|
||||
} catch (error) {
|
||||
streamResult = {
|
||||
method: 'streaming',
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
results.push({
|
||||
size: test.name,
|
||||
fullLoad: fullLoadResult,
|
||||
streaming: streamResult,
|
||||
memoryRatio: streamResult.memory && fullLoadResult.memory ?
|
||||
streamResult.memory / fullLoadResult.memory : null
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
streamingComparison.forEach(result => {
|
||||
if (result.streaming.success && result.fullLoad.success) {
|
||||
t.ok(result.memoryRatio < 0.5,
|
||||
`Streaming uses less memory for ${result.size}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Memory-mapped file processing
|
||||
const memoryMappedProcessing = await performanceTracker.measureAsync(
|
||||
'memory-mapped-processing',
|
||||
async () => {
|
||||
const testFile = path.join(process.cwd(), '.nogit', 'large-test.xml');
|
||||
const fileSize = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
try {
|
||||
// Create large test file if it doesn't exist
|
||||
if (!fs.existsSync(testFile)) {
|
||||
const dir = path.dirname(testFile);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file in chunks
|
||||
const stream = fs.createWriteStream(testFile);
|
||||
stream.write('<?xml version="1.0" encoding="UTF-8"?><Invoice><Items>');
|
||||
|
||||
const chunkSize = 1024 * 1024; // 1MB chunks
|
||||
const chunk = '<Item>' + 'X'.repeat(chunkSize - 14) + '</Item>';
|
||||
const chunks = Math.floor(fileSize / chunkSize);
|
||||
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
stream.write(chunk);
|
||||
}
|
||||
|
||||
stream.write('</Items></Invoice>');
|
||||
stream.end();
|
||||
}
|
||||
|
||||
// Process with memory mapping
|
||||
const startTime = Date.now();
|
||||
const startMem = process.memoryUsage();
|
||||
|
||||
const result = await einvoice.processLargeFile(testFile, {
|
||||
useMemoryMapping: true,
|
||||
chunkSize: 10 * 1024 * 1024 // 10MB chunks
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMem = process.memoryUsage();
|
||||
|
||||
// Clean up
|
||||
if (fs.existsSync(testFile)) {
|
||||
fs.unlinkSync(testFile);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileSize,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMem.heapUsed - startMem.heapUsed,
|
||||
throughputMBps: (fileSize / (1024 * 1024)) / ((endTime - startTime) / 1000)
|
||||
};
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
if (fs.existsSync(testFile)) {
|
||||
fs.unlinkSync(testFile);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(memoryMappedProcessing.success || memoryMappedProcessing.error,
|
||||
'Memory-mapped processing completed');
|
||||
|
||||
// Test 5: Concurrent large file processing
|
||||
const concurrentLargeFiles = await performanceTracker.measureAsync(
|
||||
'concurrent-large-files',
|
||||
async () => {
|
||||
const fileCount = 5;
|
||||
const fileSize = 50 * 1024 * 1024; // 50MB each
|
||||
|
||||
const promises = [];
|
||||
const startTime = Date.now();
|
||||
const startMem = process.memoryUsage();
|
||||
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const xml = generateLargeInvoice(fileSize);
|
||||
|
||||
promises.push(
|
||||
einvoice.parseWithStreaming(xml)
|
||||
.then(() => ({ fileId: i, success: true }))
|
||||
.catch(error => ({ fileId: i, success: false, error: error.message }))
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMem = process.memoryUsage();
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
|
||||
return {
|
||||
totalFiles: fileCount,
|
||||
successful,
|
||||
failed: fileCount - successful,
|
||||
totalTime: endTime - startTime,
|
||||
totalMemory: endMem.heapUsed - startMem.heapUsed,
|
||||
avgTimePerFile: (endTime - startTime) / fileCount,
|
||||
results
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(concurrentLargeFiles.successful > 0, 'Some concurrent large files were processed');
|
||||
|
||||
// Test 6: Progressive loading with backpressure
|
||||
const progressiveLoading = await performanceTracker.measureAsync(
|
||||
'progressive-loading-backpressure',
|
||||
async () => {
|
||||
const totalSize = 200 * 1024 * 1024; // 200MB
|
||||
const chunkSize = 10 * 1024 * 1024; // 10MB chunks
|
||||
|
||||
const results = {
|
||||
chunksProcessed: 0,
|
||||
backpressureEvents: 0,
|
||||
memoryPeaks: [],
|
||||
processingTimes: []
|
||||
};
|
||||
|
||||
try {
|
||||
for (let offset = 0; offset < totalSize; offset += chunkSize) {
|
||||
const chunkData = generateInvoiceChunk(offset, Math.min(chunkSize, totalSize - offset));
|
||||
|
||||
const chunkStart = Date.now();
|
||||
const memBefore = process.memoryUsage();
|
||||
|
||||
// Check for backpressure
|
||||
if (memBefore.heapUsed > 300 * 1024 * 1024) {
|
||||
results.backpressureEvents++;
|
||||
|
||||
// Wait for memory to reduce
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await einvoice.processChunk(chunkData, {
|
||||
isFirst: offset === 0,
|
||||
isLast: offset + chunkSize >= totalSize
|
||||
});
|
||||
|
||||
const chunkEnd = Date.now();
|
||||
const memAfter = process.memoryUsage();
|
||||
|
||||
results.chunksProcessed++;
|
||||
results.processingTimes.push(chunkEnd - chunkStart);
|
||||
results.memoryPeaks.push(memAfter.heapUsed);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...results,
|
||||
avgProcessingTime: results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length,
|
||||
maxMemoryPeak: Math.max(...results.memoryPeaks)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
...results
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(progressiveLoading.chunksProcessed > 0, 'Progressive loading processed chunks');
|
||||
t.ok(progressiveLoading.backpressureEvents >= 0, 'Backpressure was handled');
|
||||
|
||||
// Test 7: Large attachment handling
|
||||
const largeAttachments = await performanceTracker.measureAsync(
|
||||
'large-attachment-handling',
|
||||
async () => {
|
||||
const attachmentSizes = [
|
||||
{ size: 10 * 1024 * 1024, name: '10MB' },
|
||||
{ size: 50 * 1024 * 1024, name: '50MB' },
|
||||
{ size: 100 * 1024 * 1024, name: '100MB' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const attachment of attachmentSizes) {
|
||||
try {
|
||||
// Create PDF with large attachment
|
||||
const largePDF = createPDFWithAttachment(attachment.size);
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMem = process.memoryUsage();
|
||||
|
||||
const extracted = await einvoice.extractFromPDF(largePDF, {
|
||||
streamAttachments: true
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMem = process.memoryUsage();
|
||||
|
||||
results.push({
|
||||
size: attachment.name,
|
||||
success: true,
|
||||
hasAttachment: !!extracted?.attachments?.length,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMem.heapUsed - startMem.heapUsed
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
size: attachment.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
largeAttachments.forEach(result => {
|
||||
t.ok(result.success || result.error, `${result.size} attachment was processed`);
|
||||
});
|
||||
|
||||
// Test 8: Format conversion of large files
|
||||
const largeFormatConversion = await performanceTracker.measureAsync(
|
||||
'large-format-conversion',
|
||||
async () => {
|
||||
const testSizes = [10, 50]; // MB
|
||||
const results = [];
|
||||
|
||||
for (const sizeMB of testSizes) {
|
||||
const size = sizeMB * 1024 * 1024;
|
||||
const largeUBL = generateLargeUBLInvoice(size);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const startMem = process.memoryUsage();
|
||||
|
||||
const converted = await einvoice.convertFormat(largeUBL, 'cii', {
|
||||
streaming: true
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMem = process.memoryUsage();
|
||||
|
||||
results.push({
|
||||
sizeMB,
|
||||
success: true,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMem.heapUsed - startMem.heapUsed,
|
||||
throughputMBps: sizeMB / ((endTime - startTime) / 1000)
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
sizeMB,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
largeFormatConversion.forEach(result => {
|
||||
t.ok(result.success || result.error, `${result.sizeMB}MB conversion completed`);
|
||||
});
|
||||
|
||||
// Test 9: Validation of gigabyte files
|
||||
const gigabyteValidation = await performanceTracker.measureAsync(
|
||||
'gigabyte-file-validation',
|
||||
async () => {
|
||||
// Simulate validation of 1GB file
|
||||
const fileSize = 1024 * 1024 * 1024; // 1GB
|
||||
const chunkSize = 50 * 1024 * 1024; // 50MB chunks
|
||||
|
||||
const validationResults = {
|
||||
chunksValidated: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
timeTaken: 0
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const totalChunks = Math.ceil(fileSize / chunkSize);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
// Simulate chunk validation
|
||||
const chunkValidation = await einvoice.validateChunk({
|
||||
chunkIndex: i,
|
||||
totalChunks,
|
||||
size: Math.min(chunkSize, fileSize - i * chunkSize)
|
||||
});
|
||||
|
||||
validationResults.chunksValidated++;
|
||||
|
||||
if (chunkValidation?.errors) {
|
||||
validationResults.errors.push(...chunkValidation.errors);
|
||||
}
|
||||
if (chunkValidation?.warnings) {
|
||||
validationResults.warnings.push(...chunkValidation.warnings);
|
||||
}
|
||||
|
||||
// Simulate memory pressure
|
||||
if (i % 5 === 0 && global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
validationResults.timeTaken = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...validationResults,
|
||||
throughputMBps: (fileSize / (1024 * 1024)) / (validationResults.timeTaken / 1000)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
...validationResults
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(gigabyteValidation.chunksValidated > 0, 'Gigabyte file validation progressed');
|
||||
|
||||
// Test 10: Recovery after large file processing
|
||||
const largeFileRecovery = await performanceTracker.measureAsync(
|
||||
'large-file-recovery',
|
||||
async () => {
|
||||
const results = {
|
||||
largeFileProcessed: false,
|
||||
memoryRecovered: false,
|
||||
normalFileAfter: false
|
||||
};
|
||||
|
||||
// Get baseline memory
|
||||
if (global.gc) global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const baselineMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
// Process large file
|
||||
try {
|
||||
const largeXML = generateLargeInvoice(100 * 1024 * 1024); // 100MB
|
||||
await einvoice.parseDocument(largeXML);
|
||||
results.largeFileProcessed = true;
|
||||
} catch (error) {
|
||||
// Expected for very large files
|
||||
}
|
||||
|
||||
// Force cleanup
|
||||
if (global.gc) global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const afterCleanupMemory = process.memoryUsage().heapUsed;
|
||||
results.memoryRecovered = afterCleanupMemory < baselineMemory + 50 * 1024 * 1024; // Within 50MB
|
||||
|
||||
// Try normal file
|
||||
try {
|
||||
const normalXML = '<?xml version="1.0"?><Invoice><ID>NORMAL</ID></Invoice>';
|
||||
await einvoice.parseDocument(normalXML);
|
||||
results.normalFileAfter = true;
|
||||
} catch (error) {
|
||||
// Should not happen
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(largeFileRecovery.memoryRecovered, 'Memory was recovered after large file');
|
||||
t.ok(largeFileRecovery.normalFileAfter, 'Normal processing works after large file');
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Helper function to generate large invoice
|
||||
function generateLargeInvoice(targetSize: number): string {
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?><Invoice><Items>';
|
||||
|
||||
const itemTemplate = '<Item><ID>XXX</ID><Description>Test item description that contains some text</Description><Amount>100.00</Amount></Item>';
|
||||
const itemSize = itemTemplate.length;
|
||||
const itemCount = Math.floor(targetSize / itemSize);
|
||||
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
xml += itemTemplate.replace('XXX', i.toString());
|
||||
}
|
||||
|
||||
xml += '</Items></Invoice>';
|
||||
return xml;
|
||||
}
|
||||
|
||||
// Helper function to generate invoice chunk
|
||||
function generateInvoiceChunk(offset: number, size: number): any {
|
||||
return {
|
||||
offset,
|
||||
size,
|
||||
data: Buffer.alloc(size, 'A')
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create PDF with attachment
|
||||
function createPDFWithAttachment(attachmentSize: number): Buffer {
|
||||
// Simplified mock - in reality would create actual PDF
|
||||
return Buffer.alloc(attachmentSize + 1024, 'P');
|
||||
}
|
||||
|
||||
// Helper function to generate large UBL invoice
|
||||
function generateLargeUBLInvoice(size: number): string {
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>LARGE-UBL-001</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
<InvoiceLines>`;
|
||||
|
||||
const lineTemplate = `<InvoiceLine><ID>X</ID><InvoicedQuantity>1</InvoicedQuantity><LineExtensionAmount>100</LineExtensionAmount></InvoiceLine>`;
|
||||
const lineSize = lineTemplate.length;
|
||||
const lineCount = Math.floor(size / lineSize);
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
xml += lineTemplate.replace('X', i.toString());
|
||||
}
|
||||
|
||||
xml += '</InvoiceLines></Invoice>';
|
||||
return xml;
|
||||
}
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
651
test/suite/einvoice_edge-cases/test.edge-03.deep-nesting.ts
Normal file
651
test/suite/einvoice_edge-cases/test.edge-03.deep-nesting.ts
Normal file
@ -0,0 +1,651 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-03: Deeply Nested XML Structures');
|
||||
|
||||
tap.test('EDGE-03: Deeply Nested XML Structures - should handle extremely nested XML', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Linear deep nesting
|
||||
const linearDeepNesting = await performanceTracker.measureAsync(
|
||||
'linear-deep-nesting',
|
||||
async () => {
|
||||
const testDepths = [10, 100, 1000, 5000, 10000];
|
||||
const results = [];
|
||||
|
||||
for (const depth of testDepths) {
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
|
||||
// Build deeply nested structure
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += ' '.repeat(i) + `<Level${i}>\n`;
|
||||
}
|
||||
|
||||
xml += ' '.repeat(depth) + '<Data>Invoice Data</Data>\n';
|
||||
|
||||
// Close all tags
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += ' '.repeat(i) + `</Level${i}>\n`;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
try {
|
||||
const result = await einvoice.parseXML(xml);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
success: true,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMemory.heapUsed - startMemory.heapUsed,
|
||||
hasData: !!result
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth,
|
||||
success: false,
|
||||
error: error.message,
|
||||
isStackOverflow: error.message.includes('stack') || error.message.includes('depth')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
linearDeepNesting.forEach(result => {
|
||||
if (result.depth <= 1000) {
|
||||
t.ok(result.success, `Depth ${result.depth} should be handled`);
|
||||
} else {
|
||||
t.ok(!result.success || result.isStackOverflow, `Extreme depth ${result.depth} should be limited`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 2: Recursive element nesting
|
||||
const recursiveElementNesting = await performanceTracker.measureAsync(
|
||||
'recursive-element-nesting',
|
||||
async () => {
|
||||
const createRecursiveStructure = (depth: number): string => {
|
||||
if (depth === 0) {
|
||||
return '<Amount>100.00</Amount>';
|
||||
}
|
||||
|
||||
return `<Item>
|
||||
<ID>ITEM-${depth}</ID>
|
||||
<SubItems>
|
||||
${createRecursiveStructure(depth - 1)}
|
||||
</SubItems>
|
||||
</Item>`;
|
||||
};
|
||||
|
||||
const testDepths = [5, 10, 20, 50];
|
||||
const results = [];
|
||||
|
||||
for (const depth of testDepths) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>RECURSIVE-001</ID>
|
||||
<Items>
|
||||
${createRecursiveStructure(depth)}
|
||||
</Items>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const endTime = Date.now();
|
||||
|
||||
// Count actual depth
|
||||
let actualDepth = 0;
|
||||
let current = parsed;
|
||||
while (current?.Items || current?.SubItems) {
|
||||
actualDepth++;
|
||||
current = current.Items || current.SubItems;
|
||||
}
|
||||
|
||||
results.push({
|
||||
requestedDepth: depth,
|
||||
actualDepth,
|
||||
success: true,
|
||||
timeTaken: endTime - startTime
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
requestedDepth: depth,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
recursiveElementNesting.forEach(result => {
|
||||
t.ok(result.success || result.error, `Recursive depth ${result.requestedDepth} was processed`);
|
||||
});
|
||||
|
||||
// Test 3: Namespace nesting complexity
|
||||
const namespaceNesting = await performanceTracker.measureAsync(
|
||||
'namespace-nesting-complexity',
|
||||
async () => {
|
||||
const createNamespaceNesting = (depth: number): string => {
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
|
||||
// Create nested elements with different namespaces
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += ' '.repeat(i) + `<ns${i}:Element xmlns:ns${i}="http://example.com/ns${i}">\n`;
|
||||
}
|
||||
|
||||
xml += ' '.repeat(depth) + '<Data>Content</Data>\n';
|
||||
|
||||
// Close all namespace elements
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += ' '.repeat(i) + `</ns${i}:Element>\n`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
};
|
||||
|
||||
const testDepths = [5, 10, 25, 50, 100];
|
||||
const results = [];
|
||||
|
||||
for (const depth of testDepths) {
|
||||
const xml = createNamespaceNesting(depth);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const endTime = Date.now();
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
success: true,
|
||||
timeTaken: endTime - startTime,
|
||||
namespacesPreserved: true // Check if namespaces were preserved
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
namespaceNesting.forEach(result => {
|
||||
if (result.depth <= 50) {
|
||||
t.ok(result.success, `Namespace depth ${result.depth} should be handled`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Mixed content deep nesting
|
||||
const mixedContentNesting = await performanceTracker.measureAsync(
|
||||
'mixed-content-deep-nesting',
|
||||
async () => {
|
||||
const createMixedNesting = (depth: number): string => {
|
||||
let xml = '';
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += `<Level${i}>Text before `;
|
||||
}
|
||||
|
||||
xml += '<Value>Core Value</Value>';
|
||||
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += ` text after</Level${i}>`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
};
|
||||
|
||||
const testCases = [10, 50, 100, 500];
|
||||
const results = [];
|
||||
|
||||
for (const depth of testCases) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<MixedContent>
|
||||
${createMixedNesting(depth)}
|
||||
</MixedContent>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
success: true,
|
||||
hasMixedContent: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
mixedContentNesting.forEach(result => {
|
||||
t.ok(result.success || result.error, `Mixed content depth ${result.depth} was handled`);
|
||||
});
|
||||
|
||||
// Test 5: Attribute-heavy deep nesting
|
||||
const attributeHeavyNesting = await performanceTracker.measureAsync(
|
||||
'attribute-heavy-nesting',
|
||||
async () => {
|
||||
const createAttributeNesting = (depth: number, attrsPerLevel: number): string => {
|
||||
let xml = '';
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += `<Element${i}`;
|
||||
|
||||
// Add multiple attributes at each level
|
||||
for (let j = 0; j < attrsPerLevel; j++) {
|
||||
xml += ` attr${j}="value${i}_${j}"`;
|
||||
}
|
||||
|
||||
xml += '>';
|
||||
}
|
||||
|
||||
xml += 'Content';
|
||||
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += `</Element${i}>`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
};
|
||||
|
||||
const testCases = [
|
||||
{ depth: 10, attrs: 10 },
|
||||
{ depth: 50, attrs: 5 },
|
||||
{ depth: 100, attrs: 3 },
|
||||
{ depth: 500, attrs: 1 }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of testCases) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
${createAttributeNesting(test.depth, test.attrs)}
|
||||
</Invoice>`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
try {
|
||||
await einvoice.parseXML(xml);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.push({
|
||||
depth: test.depth,
|
||||
attributesPerLevel: test.attrs,
|
||||
totalAttributes: test.depth * test.attrs,
|
||||
success: true,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMemory.heapUsed - startMemory.heapUsed
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth: test.depth,
|
||||
attributesPerLevel: test.attrs,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
attributeHeavyNesting.forEach(result => {
|
||||
t.ok(result.success || result.error,
|
||||
`Attribute-heavy nesting (depth: ${result.depth}, attrs: ${result.attributesPerLevel}) was processed`);
|
||||
});
|
||||
|
||||
// Test 6: CDATA section nesting
|
||||
const cdataNesting = await performanceTracker.measureAsync(
|
||||
'cdata-section-nesting',
|
||||
async () => {
|
||||
const depths = [5, 10, 20, 50];
|
||||
const results = [];
|
||||
|
||||
for (const depth of depths) {
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?><Invoice>';
|
||||
|
||||
// Create nested elements with CDATA
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += `<Level${i}><![CDATA[Data at level ${i} with <special> characters & symbols]]>`;
|
||||
}
|
||||
|
||||
// Close all elements
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += `</Level${i}>`;
|
||||
}
|
||||
|
||||
xml += '</Invoice>';
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
success: true,
|
||||
cdataPreserved: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
cdataNesting.forEach(result => {
|
||||
t.ok(result.success, `CDATA nesting depth ${result.depth} should be handled`);
|
||||
});
|
||||
|
||||
// Test 7: Processing instruction nesting
|
||||
const processingInstructionNesting = await performanceTracker.measureAsync(
|
||||
'processing-instruction-nesting',
|
||||
async () => {
|
||||
const createPINesting = (depth: number): string => {
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += `<?process-level-${i} instruction="value"?>\n`;
|
||||
xml += `<Level${i}>\n`;
|
||||
}
|
||||
|
||||
xml += '<Data>Content</Data>\n';
|
||||
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += `</Level${i}>\n`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
};
|
||||
|
||||
const depths = [10, 25, 50];
|
||||
const results = [];
|
||||
|
||||
for (const depth of depths) {
|
||||
const xml = createPINesting(depth);
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
success: true,
|
||||
processingInstructionsHandled: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
processingInstructionNesting.forEach(result => {
|
||||
t.ok(result.success, `PI nesting depth ${result.depth} should be handled`);
|
||||
});
|
||||
|
||||
// Test 8: Real invoice format deep structures
|
||||
const realFormatDeepStructures = await performanceTracker.measureAsync(
|
||||
'real-format-deep-structures',
|
||||
async () => {
|
||||
const formats = ['ubl', 'cii'];
|
||||
const results = [];
|
||||
|
||||
for (const format of formats) {
|
||||
// Create deeply nested invoice structure
|
||||
let invoice;
|
||||
|
||||
if (format === 'ubl') {
|
||||
invoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>DEEP-UBL-001</ID>
|
||||
<Note>
|
||||
<SubNote>
|
||||
<SubSubNote>
|
||||
<Content>
|
||||
<Detail>
|
||||
<SubDetail>
|
||||
<Information>Deeply nested note</Information>
|
||||
</SubDetail>
|
||||
</Detail>
|
||||
</Content>
|
||||
</SubSubNote>
|
||||
</SubNote>
|
||||
</Note>
|
||||
<InvoiceLine>
|
||||
<Item>
|
||||
<AdditionalItemProperty>
|
||||
<Value>
|
||||
<SubValue>
|
||||
<Detail>
|
||||
<SubDetail>
|
||||
<Information>Deep item property</Information>
|
||||
</SubDetail>
|
||||
</Detail>
|
||||
</SubValue>
|
||||
</Value>
|
||||
</AdditionalItemProperty>
|
||||
</Item>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
} else {
|
||||
invoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>DEEP-CII-001</ram:ID>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>
|
||||
<ram:SubContent>
|
||||
<ram:Detail>
|
||||
<ram:SubDetail>
|
||||
<ram:Information>Deep CII structure</ram:Information>
|
||||
</ram:SubDetail>
|
||||
</ram:Detail>
|
||||
</ram:SubContent>
|
||||
</ram:Content>
|
||||
</ram:IncludedNote>
|
||||
</rsm:ExchangedDocument>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(invoice);
|
||||
const validated = await einvoice.validate(parsed);
|
||||
|
||||
results.push({
|
||||
format,
|
||||
parsed: true,
|
||||
valid: validated?.isValid || false,
|
||||
deepStructureSupported: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
format,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
realFormatDeepStructures.forEach(result => {
|
||||
t.ok(result.parsed, `${result.format} deep structure should be parsed`);
|
||||
});
|
||||
|
||||
// Test 9: Stack overflow protection
|
||||
const stackOverflowProtection = await performanceTracker.measureAsync(
|
||||
'stack-overflow-protection',
|
||||
async () => {
|
||||
const extremeDepths = [10000, 50000, 100000];
|
||||
const results = [];
|
||||
|
||||
for (const depth of extremeDepths) {
|
||||
// Create extremely deep structure efficiently
|
||||
const parts = [];
|
||||
parts.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
|
||||
// Opening tags
|
||||
for (let i = 0; i < Math.min(depth, 1000); i++) {
|
||||
parts.push(`<L${i}>`);
|
||||
}
|
||||
|
||||
parts.push('<Data>Test</Data>');
|
||||
|
||||
// Closing tags
|
||||
for (let i = Math.min(depth - 1, 999); i >= 0; i--) {
|
||||
parts.push(`</L${i}>`);
|
||||
}
|
||||
|
||||
const xml = parts.join('');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await einvoice.parseXML(xml, { maxDepth: 1000 });
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
protected: true,
|
||||
method: 'depth-limit',
|
||||
timeTaken: endTime - startTime
|
||||
});
|
||||
} catch (error) {
|
||||
const endTime = Date.now();
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
protected: true,
|
||||
method: error.message.includes('depth') ? 'depth-check' : 'stack-guard',
|
||||
timeTaken: endTime - startTime,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
stackOverflowProtection.forEach(result => {
|
||||
t.ok(result.protected, `Stack overflow protection active for depth ${result.depth}`);
|
||||
});
|
||||
|
||||
// Test 10: Performance impact of nesting
|
||||
const nestingPerformanceImpact = await performanceTracker.measureAsync(
|
||||
'nesting-performance-impact',
|
||||
async () => {
|
||||
const depths = [1, 10, 50, 100, 500, 1000];
|
||||
const results = [];
|
||||
|
||||
for (const depth of depths) {
|
||||
// Create invoice with specific nesting depth
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?><Invoice>';
|
||||
|
||||
// Create structure at depth
|
||||
let current = xml;
|
||||
for (let i = 0; i < depth; i++) {
|
||||
current += `<Item${i}>`;
|
||||
}
|
||||
|
||||
current += '<ID>TEST</ID><Amount>100</Amount>';
|
||||
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
current += `</Item${i}>`;
|
||||
}
|
||||
|
||||
current += '</Invoice>';
|
||||
|
||||
// Measure parsing time
|
||||
const iterations = 10;
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
await einvoice.parseXML(current);
|
||||
} catch (error) {
|
||||
// Ignore errors for performance testing
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
times.push(Number(endTime - startTime) / 1000000); // Convert to ms
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
const minTime = Math.min(...times);
|
||||
const maxTime = Math.max(...times);
|
||||
|
||||
results.push({
|
||||
depth,
|
||||
avgTime,
|
||||
minTime,
|
||||
maxTime,
|
||||
complexity: avgTime / depth // Time per nesting level
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Verify performance doesn't degrade exponentially
|
||||
const complexities = nestingPerformanceImpact.map(r => r.complexity);
|
||||
const avgComplexity = complexities.reduce((a, b) => a + b, 0) / complexities.length;
|
||||
|
||||
nestingPerformanceImpact.forEach(result => {
|
||||
t.ok(result.complexity < avgComplexity * 10,
|
||||
`Nesting depth ${result.depth} has reasonable performance`);
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
656
test/suite/einvoice_edge-cases/test.edge-04.unusual-charsets.ts
Normal file
656
test/suite/einvoice_edge-cases/test.edge-04.unusual-charsets.ts
Normal file
@ -0,0 +1,656 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-04: Unusual Character Sets');
|
||||
|
||||
tap.test('EDGE-04: Unusual Character Sets - should handle unusual and exotic character encodings', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Unicode edge cases
|
||||
const unicodeEdgeCases = await performanceTracker.measureAsync(
|
||||
'unicode-edge-cases',
|
||||
async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'zero-width-characters',
|
||||
text: 'Invoice\u200B\u200C\u200D\uFEFFNumber',
|
||||
description: 'Zero-width spaces and joiners'
|
||||
},
|
||||
{
|
||||
name: 'right-to-left',
|
||||
text: 'مرحبا INV-001 שלום',
|
||||
description: 'RTL Arabic and Hebrew mixed with LTR'
|
||||
},
|
||||
{
|
||||
name: 'surrogate-pairs',
|
||||
text: '𝐇𝐞𝐥𝐥𝐨 😀 🎉 Invoice',
|
||||
description: 'Mathematical bold text and emojis'
|
||||
},
|
||||
{
|
||||
name: 'combining-characters',
|
||||
text: 'Ińvȯíçë̃ Nüm̈bër̊',
|
||||
description: 'Combining diacritical marks'
|
||||
},
|
||||
{
|
||||
name: 'control-characters',
|
||||
text: 'Invoice\x00\x01\x02\x1F\x7FTest',
|
||||
description: 'Control characters'
|
||||
},
|
||||
{
|
||||
name: 'bidi-override',
|
||||
text: '\u202Eتسا Invoice 123\u202C',
|
||||
description: 'Bidirectional override characters'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>${testCase.text}</ID>
|
||||
<Description>${testCase.description}</Description>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const idValue = parsed?.ID || '';
|
||||
|
||||
results.push({
|
||||
name: testCase.name,
|
||||
success: true,
|
||||
preserved: idValue === testCase.text,
|
||||
normalized: idValue !== testCase.text,
|
||||
parsedValue: idValue,
|
||||
originalLength: testCase.text.length,
|
||||
parsedLength: idValue.length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: testCase.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
unicodeEdgeCases.forEach(result => {
|
||||
t.ok(result.success, `Unicode edge case ${result.name} should be handled`);
|
||||
});
|
||||
|
||||
// Test 2: Various character encodings
|
||||
const characterEncodings = await performanceTracker.measureAsync(
|
||||
'various-character-encodings',
|
||||
async () => {
|
||||
const encodings = [
|
||||
{
|
||||
encoding: 'UTF-8',
|
||||
bom: Buffer.from([0xEF, 0xBB, 0xBF]),
|
||||
text: 'Übung macht den Meister'
|
||||
},
|
||||
{
|
||||
encoding: 'UTF-16BE',
|
||||
bom: Buffer.from([0xFE, 0xFF]),
|
||||
text: 'Invoice \u4E2D\u6587'
|
||||
},
|
||||
{
|
||||
encoding: 'UTF-16LE',
|
||||
bom: Buffer.from([0xFF, 0xFE]),
|
||||
text: 'Facture française'
|
||||
},
|
||||
{
|
||||
encoding: 'ISO-8859-1',
|
||||
bom: null,
|
||||
text: 'Ñoño español'
|
||||
},
|
||||
{
|
||||
encoding: 'Windows-1252',
|
||||
bom: null,
|
||||
text: 'Smart "quotes" and —dashes'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const enc of encodings) {
|
||||
const xmlContent = `<?xml version="1.0" encoding="${enc.encoding}"?>
|
||||
<Invoice>
|
||||
<ID>ENC-001</ID>
|
||||
<CustomerName>${enc.text}</CustomerName>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
// Create buffer with proper encoding
|
||||
let buffer;
|
||||
if (enc.bom) {
|
||||
const textBuffer = Buffer.from(xmlContent, enc.encoding.toLowerCase());
|
||||
buffer = Buffer.concat([enc.bom, textBuffer]);
|
||||
} else {
|
||||
buffer = Buffer.from(xmlContent, enc.encoding.toLowerCase().replace('-', ''));
|
||||
}
|
||||
|
||||
const parsed = await einvoice.parseDocument(buffer);
|
||||
|
||||
results.push({
|
||||
encoding: enc.encoding,
|
||||
success: true,
|
||||
hasBOM: !!enc.bom,
|
||||
textPreserved: parsed?.CustomerName === enc.text
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
encoding: enc.encoding,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
characterEncodings.forEach(result => {
|
||||
t.ok(result.success || result.error, `Encoding ${result.encoding} was processed`);
|
||||
});
|
||||
|
||||
// Test 3: Emoji and pictographic characters
|
||||
const emojiAndPictographs = await performanceTracker.measureAsync(
|
||||
'emoji-and-pictographs',
|
||||
async () => {
|
||||
const emojiTests = [
|
||||
{
|
||||
name: 'basic-emoji',
|
||||
content: 'Invoice 📧 sent ✅'
|
||||
},
|
||||
{
|
||||
name: 'flag-emoji',
|
||||
content: 'Country: 🇺🇸 🇬🇧 🇩🇪 🇫🇷'
|
||||
},
|
||||
{
|
||||
name: 'skin-tone-emoji',
|
||||
content: 'Approved by 👍🏻👍🏼👍🏽👍🏾👍🏿'
|
||||
},
|
||||
{
|
||||
name: 'zwj-sequences',
|
||||
content: 'Family: 👨👩👧👦'
|
||||
},
|
||||
{
|
||||
name: 'mixed-emoji-text',
|
||||
content: '💰 Total: €1,234.56 💶'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of emojiTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>EMOJI-001</ID>
|
||||
<Note>${test.content}</Note>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const noteValue = parsed?.Note || '';
|
||||
|
||||
// Count grapheme clusters (visual characters)
|
||||
const graphemeCount = [...new Intl.Segmenter().segment(test.content)].length;
|
||||
const preservedGraphemes = [...new Intl.Segmenter().segment(noteValue)].length;
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
success: true,
|
||||
preserved: noteValue === test.content,
|
||||
originalGraphemes: graphemeCount,
|
||||
preservedGraphemes,
|
||||
codePointCount: Array.from(test.content).length,
|
||||
byteLength: Buffer.from(test.content, 'utf8').length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: test.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
emojiAndPictographs.forEach(result => {
|
||||
t.ok(result.success, `Emoji test ${result.name} should succeed`);
|
||||
if (result.success) {
|
||||
t.ok(result.preserved, `Emoji content should be preserved`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Legacy and exotic scripts
|
||||
const exoticScripts = await performanceTracker.measureAsync(
|
||||
'exotic-scripts',
|
||||
async () => {
|
||||
const scripts = [
|
||||
{ name: 'chinese-traditional', text: '發票編號:貳零貳肆' },
|
||||
{ name: 'japanese-mixed', text: '請求書番号:2024年' },
|
||||
{ name: 'korean', text: '송장 번호: 2024' },
|
||||
{ name: 'thai', text: 'ใบแจ้งหนี้: ๒๐๒๔' },
|
||||
{ name: 'devanagari', text: 'चालान संख्या: २०२४' },
|
||||
{ name: 'cyrillic', text: 'Счёт-фактура № 2024' },
|
||||
{ name: 'greek', text: 'Τιμολόγιο: ΜΜΚΔ' },
|
||||
{ name: 'ethiopic', text: 'ቁጥር: ፪፻፳፬' },
|
||||
{ name: 'bengali', text: 'চালান নং: ২০২৪' },
|
||||
{ name: 'tamil', text: 'விலைப்பட்டியல்: ௨௦௨௪' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const script of scripts) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>SCRIPT-${script.name}</ID>
|
||||
<Description>${script.text}</Description>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const description = parsed?.Description || '';
|
||||
|
||||
results.push({
|
||||
script: script.name,
|
||||
success: true,
|
||||
preserved: description === script.text,
|
||||
charCount: script.text.length,
|
||||
byteCount: Buffer.from(script.text, 'utf8').length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
script: script.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
exoticScripts.forEach(result => {
|
||||
t.ok(result.success, `Script ${result.script} should be handled`);
|
||||
if (result.success) {
|
||||
t.ok(result.preserved, `Script ${result.script} content should be preserved`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: Invalid UTF-8 sequences
|
||||
const invalidUTF8 = await performanceTracker.measureAsync(
|
||||
'invalid-utf8-sequences',
|
||||
async () => {
|
||||
const invalidSequences = [
|
||||
{
|
||||
name: 'orphan-continuation',
|
||||
bytes: Buffer.from([0x80, 0x81, 0x82])
|
||||
},
|
||||
{
|
||||
name: 'incomplete-sequence',
|
||||
bytes: Buffer.from([0xC2])
|
||||
},
|
||||
{
|
||||
name: 'overlong-encoding',
|
||||
bytes: Buffer.from([0xC0, 0x80])
|
||||
},
|
||||
{
|
||||
name: 'invalid-start',
|
||||
bytes: Buffer.from([0xF8, 0x80, 0x80, 0x80])
|
||||
},
|
||||
{
|
||||
name: 'mixed-valid-invalid',
|
||||
bytes: Buffer.concat([
|
||||
Buffer.from('Valid '),
|
||||
Buffer.from([0xFF, 0xFE]),
|
||||
Buffer.from(' Text')
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const seq of invalidSequences) {
|
||||
const xmlStart = Buffer.from('<?xml version="1.0" encoding="UTF-8"?><Invoice><ID>');
|
||||
const xmlEnd = Buffer.from('</ID></Invoice>');
|
||||
const fullBuffer = Buffer.concat([xmlStart, seq.bytes, xmlEnd]);
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(fullBuffer);
|
||||
|
||||
results.push({
|
||||
name: seq.name,
|
||||
handled: true,
|
||||
recovered: !!parsed,
|
||||
replacedWithPlaceholder: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: seq.name,
|
||||
handled: true,
|
||||
rejected: true,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
invalidUTF8.forEach(result => {
|
||||
t.ok(result.handled, `Invalid UTF-8 ${result.name} was handled`);
|
||||
});
|
||||
|
||||
// Test 6: Normalization forms
|
||||
const normalizationForms = await performanceTracker.measureAsync(
|
||||
'unicode-normalization-forms',
|
||||
async () => {
|
||||
const testText = 'Café'; // Can be represented differently
|
||||
const forms = [
|
||||
{ name: 'NFC', text: testText.normalize('NFC') },
|
||||
{ name: 'NFD', text: testText.normalize('NFD') },
|
||||
{ name: 'NFKC', text: testText.normalize('NFKC') },
|
||||
{ name: 'NFKD', text: testText.normalize('NFKD') }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const form of forms) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<CustomerName>${form.text}</CustomerName>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const name = parsed?.CustomerName || '';
|
||||
|
||||
results.push({
|
||||
form: form.name,
|
||||
success: true,
|
||||
preserved: name === form.text,
|
||||
normalized: name.normalize('NFC') === testText.normalize('NFC'),
|
||||
codePoints: Array.from(form.text).length,
|
||||
bytes: Buffer.from(form.text, 'utf8').length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
form: form.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
normalizationForms.forEach(result => {
|
||||
t.ok(result.success, `Normalization form ${result.form} should be handled`);
|
||||
if (result.success) {
|
||||
t.ok(result.normalized, `Content should be comparable after normalization`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7: Homoglyphs and confusables
|
||||
const homoglyphsAndConfusables = await performanceTracker.measureAsync(
|
||||
'homoglyphs-and-confusables',
|
||||
async () => {
|
||||
const confusables = [
|
||||
{
|
||||
name: 'latin-cyrillic-mix',
|
||||
text: 'Invоicе Numbеr', // Contains Cyrillic о and е
|
||||
description: 'Mixed Latin and Cyrillic lookalikes'
|
||||
},
|
||||
{
|
||||
name: 'greek-latin-mix',
|
||||
text: 'Ιnvoice Νumber', // Greek Ι and Ν
|
||||
description: 'Greek letters that look like Latin'
|
||||
},
|
||||
{
|
||||
name: 'fullwidth-chars',
|
||||
text: 'Invoice Number',
|
||||
description: 'Fullwidth characters'
|
||||
},
|
||||
{
|
||||
name: 'mathematical-alphanumeric',
|
||||
text: '𝐈𝐧𝐯𝐨𝐢𝐜𝐞 𝐍𝐮𝐦𝐛𝐞𝐫',
|
||||
description: 'Mathematical bold characters'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of confusables) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>${test.text}</ID>
|
||||
<Note>${test.description}</Note>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const id = parsed?.ID || '';
|
||||
|
||||
// Check if system detects potential homoglyphs
|
||||
const hasNonASCII = /[^\x00-\x7F]/.test(id);
|
||||
const normalized = id.normalize('NFKC');
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
success: true,
|
||||
preserved: id === test.text,
|
||||
hasNonASCII,
|
||||
normalized: normalized !== test.text,
|
||||
detectable: hasNonASCII || normalized !== test.text
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: test.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
homoglyphsAndConfusables.forEach(result => {
|
||||
t.ok(result.success, `Homoglyph test ${result.name} should be handled`);
|
||||
if (result.success) {
|
||||
t.ok(result.detectable, `Potential confusables should be detectable`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 8: XML special characters in unusual encodings
|
||||
const xmlSpecialInEncodings = await performanceTracker.measureAsync(
|
||||
'xml-special-characters-in-encodings',
|
||||
async () => {
|
||||
const specialChars = [
|
||||
{ char: '<', entity: '<', desc: 'less than' },
|
||||
{ char: '>', entity: '>', desc: 'greater than' },
|
||||
{ char: '&', entity: '&', desc: 'ampersand' },
|
||||
{ char: '"', entity: '"', desc: 'quote' },
|
||||
{ char: "'", entity: ''', desc: 'apostrophe' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const special of specialChars) {
|
||||
// Test both raw and entity forms
|
||||
const tests = [
|
||||
{ type: 'entity', value: special.entity },
|
||||
{ type: 'cdata', value: `<![CDATA[${special.char}]]>` },
|
||||
{ type: 'numeric', value: `&#${special.char.charCodeAt(0)};` }
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<Description>Price ${test.value} 100</Description>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const desc = parsed?.Description || '';
|
||||
|
||||
results.push({
|
||||
char: special.desc,
|
||||
method: test.type,
|
||||
success: true,
|
||||
containsChar: desc.includes(special.char),
|
||||
preserved: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
char: special.desc,
|
||||
method: test.type,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
xmlSpecialInEncodings.forEach(result => {
|
||||
t.ok(result.success, `XML special ${result.char} as ${result.method} should be handled`);
|
||||
});
|
||||
|
||||
// Test 9: Private use area characters
|
||||
const privateUseArea = await performanceTracker.measureAsync(
|
||||
'private-use-area-characters',
|
||||
async () => {
|
||||
const puaRanges = [
|
||||
{ name: 'BMP-PUA', start: 0xE000, end: 0xF8FF },
|
||||
{ name: 'Plane15-PUA', start: 0xF0000, end: 0xFFFFD },
|
||||
{ name: 'Plane16-PUA', start: 0x100000, end: 0x10FFFD }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const range of puaRanges) {
|
||||
// Test a few characters from each range
|
||||
const testChars = [];
|
||||
testChars.push(String.fromCodePoint(range.start));
|
||||
testChars.push(String.fromCodePoint(Math.floor((range.start + range.end) / 2)));
|
||||
if (range.end <= 0x10FFFF) {
|
||||
testChars.push(String.fromCodePoint(range.end));
|
||||
}
|
||||
|
||||
const testString = testChars.join('');
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<CustomField>${testString}</CustomField>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const field = parsed?.CustomField || '';
|
||||
|
||||
results.push({
|
||||
range: range.name,
|
||||
success: true,
|
||||
preserved: field === testString,
|
||||
charCount: testString.length,
|
||||
handled: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
range: range.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
privateUseArea.forEach(result => {
|
||||
t.ok(result.success || result.error, `PUA range ${result.range} was processed`);
|
||||
});
|
||||
|
||||
// Test 10: Character set conversion in format transformation
|
||||
const formatTransformCharsets = await performanceTracker.measureAsync(
|
||||
'format-transform-charsets',
|
||||
async () => {
|
||||
const testContents = [
|
||||
{ name: 'multilingual', text: 'Hello مرحبا 你好 Здравствуйте' },
|
||||
{ name: 'symbols', text: '€ £ ¥ $ ₹ ₽ ¢ ₩' },
|
||||
{ name: 'accented', text: 'àáäâ èéëê ìíïî òóöô ùúüû ñç' },
|
||||
{ name: 'mixed-emoji', text: 'Invoice 📄 Total: 💰 Status: ✅' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const content of testContents) {
|
||||
const ublInvoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>CHARSET-001</ID>
|
||||
<Note>${content.text}</Note>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
// Convert to CII
|
||||
const ciiResult = await einvoice.convertFormat(ublInvoice, 'cii');
|
||||
|
||||
// Parse the converted result
|
||||
const parsed = await einvoice.parseDocument(ciiResult);
|
||||
|
||||
// Check if content was preserved
|
||||
const preserved = JSON.stringify(parsed).includes(content.text);
|
||||
|
||||
results.push({
|
||||
content: content.name,
|
||||
success: true,
|
||||
preserved,
|
||||
formatConversionOk: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
content: content.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
formatTransformCharsets.forEach(result => {
|
||||
t.ok(result.success, `Format transform with ${result.content} should succeed`);
|
||||
if (result.success) {
|
||||
t.ok(result.preserved, `Character content should be preserved in transformation`);
|
||||
}
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
526
test/suite/einvoice_edge-cases/test.edge-05.zero-byte-pdf.ts
Normal file
526
test/suite/einvoice_edge-cases/test.edge-05.zero-byte-pdf.ts
Normal file
@ -0,0 +1,526 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-05: Zero-Byte PDFs');
|
||||
|
||||
tap.test('EDGE-05: Zero-Byte PDFs - should handle zero-byte and minimal PDF files', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Truly zero-byte PDF
|
||||
const zeroByteFile = await performanceTracker.measureAsync(
|
||||
'truly-zero-byte-pdf',
|
||||
async () => {
|
||||
const zeroPDF = Buffer.alloc(0);
|
||||
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(zeroPDF);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
hasContent: !!result,
|
||||
hasXML: result?.xml !== undefined,
|
||||
hasAttachments: result?.attachments?.length > 0,
|
||||
error: null,
|
||||
bufferSize: zeroPDF.length
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
handled: true,
|
||||
hasContent: false,
|
||||
error: error.message,
|
||||
errorType: error.constructor.name,
|
||||
bufferSize: zeroPDF.length
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(zeroByteFile.handled, 'Zero-byte PDF was handled');
|
||||
t.notOk(zeroByteFile.hasContent, 'Zero-byte PDF has no content');
|
||||
t.equal(zeroByteFile.bufferSize, 0, 'Buffer size is zero');
|
||||
|
||||
// Test 2: Minimal PDF structure
|
||||
const minimalPDFStructure = await performanceTracker.measureAsync(
|
||||
'minimal-pdf-structure',
|
||||
async () => {
|
||||
const minimalPDFs = [
|
||||
{
|
||||
name: 'header-only',
|
||||
content: Buffer.from('%PDF-1.4')
|
||||
},
|
||||
{
|
||||
name: 'header-and-eof',
|
||||
content: Buffer.from('%PDF-1.4\n%%EOF')
|
||||
},
|
||||
{
|
||||
name: 'empty-catalog',
|
||||
content: Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog >>\nendobj\n' +
|
||||
'xref\n0 2\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'trailer\n<< /Size 2 /Root 1 0 R >>\n' +
|
||||
'startxref\n64\n%%EOF'
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'single-empty-page',
|
||||
content: Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n' +
|
||||
'xref\n0 3\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000052 00000 n\n' +
|
||||
'trailer\n<< /Size 3 /Root 1 0 R >>\n' +
|
||||
'startxref\n110\n%%EOF'
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const pdf of minimalPDFs) {
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(pdf.content);
|
||||
|
||||
results.push({
|
||||
name: pdf.name,
|
||||
size: pdf.content.length,
|
||||
processed: true,
|
||||
hasXML: !!result?.xml,
|
||||
hasAttachments: result?.attachments?.length > 0,
|
||||
hasMetadata: !!result?.metadata
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: pdf.name,
|
||||
size: pdf.content.length,
|
||||
processed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
minimalPDFStructure.forEach(result => {
|
||||
t.ok(result.processed || result.error, `Minimal PDF ${result.name} was processed`);
|
||||
t.notOk(result.hasXML, `Minimal PDF ${result.name} has no XML`);
|
||||
});
|
||||
|
||||
// Test 3: Truncated PDF files
|
||||
const truncatedPDFs = await performanceTracker.measureAsync(
|
||||
'truncated-pdf-files',
|
||||
async () => {
|
||||
// Start with a valid PDF structure and truncate at different points
|
||||
const fullPDF = Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n' +
|
||||
'3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n' +
|
||||
'xref\n0 4\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000052 00000 n\n' +
|
||||
'0000000110 00000 n\n' +
|
||||
'trailer\n<< /Size 4 /Root 1 0 R >>\n' +
|
||||
'startxref\n196\n%%EOF'
|
||||
);
|
||||
|
||||
const truncationPoints = [
|
||||
{ name: 'after-header', bytes: 10 },
|
||||
{ name: 'mid-object', bytes: 50 },
|
||||
{ name: 'before-xref', bytes: 150 },
|
||||
{ name: 'mid-xref', bytes: 250 },
|
||||
{ name: 'before-eof', bytes: fullPDF.length - 5 }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const point of truncationPoints) {
|
||||
const truncated = fullPDF.slice(0, point.bytes);
|
||||
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(truncated);
|
||||
|
||||
results.push({
|
||||
truncationPoint: point.name,
|
||||
size: truncated.length,
|
||||
recovered: true,
|
||||
hasPartialData: !!result
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
truncationPoint: point.name,
|
||||
size: truncated.length,
|
||||
recovered: false,
|
||||
error: error.message,
|
||||
isCorruptionError: error.message.includes('corrupt') || error.message.includes('truncated')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
truncatedPDFs.forEach(result => {
|
||||
t.ok(!result.recovered || result.isCorruptionError,
|
||||
`Truncated PDF at ${result.truncationPoint} should fail or be detected as corrupt`);
|
||||
});
|
||||
|
||||
// Test 4: PDF with zero-byte attachment
|
||||
const zeroByteAttachment = await performanceTracker.measureAsync(
|
||||
'pdf-with-zero-byte-attachment',
|
||||
async () => {
|
||||
// Create a PDF with an embedded file of zero bytes
|
||||
const pdfWithEmptyAttachment = Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /Names 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /EmbeddedFiles 3 0 R >>\nendobj\n' +
|
||||
'3 0 obj\n<< /Names [(empty.xml) 4 0 R] >>\nendobj\n' +
|
||||
'4 0 obj\n<< /Type /Filespec /F (empty.xml) /EF << /F 5 0 R >> >>\nendobj\n' +
|
||||
'5 0 obj\n<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream\nendobj\n' +
|
||||
'xref\n0 6\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000062 00000 n\n' +
|
||||
'0000000103 00000 n\n' +
|
||||
'0000000151 00000 n\n' +
|
||||
'0000000229 00000 n\n' +
|
||||
'trailer\n<< /Size 6 /Root 1 0 R >>\n' +
|
||||
'startxref\n307\n%%EOF'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(pdfWithEmptyAttachment);
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
hasAttachments: result?.attachments?.length > 0,
|
||||
attachmentCount: result?.attachments?.length || 0,
|
||||
firstAttachmentSize: result?.attachments?.[0]?.size || 0,
|
||||
firstAttachmentName: result?.attachments?.[0]?.name || null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
processed: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(zeroByteAttachment.processed, 'PDF with zero-byte attachment was processed');
|
||||
if (zeroByteAttachment.hasAttachments) {
|
||||
t.equal(zeroByteAttachment.firstAttachmentSize, 0, 'Attachment size is zero');
|
||||
}
|
||||
|
||||
// Test 5: PDF with only metadata
|
||||
const metadataOnlyPDF = await performanceTracker.measureAsync(
|
||||
'pdf-with-only-metadata',
|
||||
async () => {
|
||||
const pdfWithMetadata = Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /Metadata 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /Type /Metadata /Subtype /XML /Length 100 >>\n' +
|
||||
'stream\n' +
|
||||
'<?xml version="1.0"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF></rdf:RDF></x:xmpmeta>\n' +
|
||||
'endstream\nendobj\n' +
|
||||
'xref\n0 3\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000068 00000 n\n' +
|
||||
'trailer\n<< /Size 3 /Root 1 0 R >>\n' +
|
||||
'startxref\n259\n%%EOF'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(pdfWithMetadata);
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
hasMetadata: !!result?.metadata,
|
||||
hasXML: !!result?.xml,
|
||||
hasContent: !!result?.content,
|
||||
isEmpty: !result?.xml && !result?.attachments?.length
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
processed: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(metadataOnlyPDF.processed, 'PDF with only metadata was processed');
|
||||
t.ok(metadataOnlyPDF.isEmpty, 'PDF with only metadata has no invoice content');
|
||||
|
||||
// Test 6: Compressed empty streams
|
||||
const compressedEmptyStreams = await performanceTracker.measureAsync(
|
||||
'compressed-empty-streams',
|
||||
async () => {
|
||||
const compressionMethods = [
|
||||
{ name: 'flate', filter: '/FlateDecode' },
|
||||
{ name: 'lzw', filter: '/LZWDecode' },
|
||||
{ name: 'ascii85', filter: '/ASCII85Decode' },
|
||||
{ name: 'asciihex', filter: '/ASCIIHexDecode' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const method of compressionMethods) {
|
||||
const pdf = Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
`1 0 obj\n<< /Length 0 /Filter ${method.filter} >>\n` +
|
||||
'stream\n\nendstream\nendobj\n' +
|
||||
'xref\n0 2\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'trailer\n<< /Size 2 >>\n' +
|
||||
'startxref\n100\n%%EOF'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await einvoice.processPDFStream(pdf);
|
||||
|
||||
results.push({
|
||||
method: method.name,
|
||||
handled: true,
|
||||
decompressed: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
method: method.name,
|
||||
handled: true,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
compressedEmptyStreams.forEach(result => {
|
||||
t.ok(result.handled, `Empty ${result.method} stream was handled`);
|
||||
});
|
||||
|
||||
// Test 7: Zero-page PDF
|
||||
const zeroPagePDF = await performanceTracker.measureAsync(
|
||||
'zero-page-pdf',
|
||||
async () => {
|
||||
const zeroPagesPDF = Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n' +
|
||||
'xref\n0 3\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000058 00000 n\n' +
|
||||
'trailer\n<< /Size 3 /Root 1 0 R >>\n' +
|
||||
'startxref\n115\n%%EOF'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(zeroPagesPDF);
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
pageCount: result?.pageCount || 0,
|
||||
hasContent: !!result?.content,
|
||||
canExtractXML: !!result?.xml
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
processed: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(zeroPagePDF.processed || zeroPagePDF.error, 'Zero-page PDF was handled');
|
||||
if (zeroPagePDF.processed) {
|
||||
t.equal(zeroPagePDF.pageCount, 0, 'Page count is zero');
|
||||
}
|
||||
|
||||
// Test 8: PDF with empty form fields
|
||||
const emptyFormFields = await performanceTracker.measureAsync(
|
||||
'pdf-with-empty-form-fields',
|
||||
async () => {
|
||||
const formPDF = Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /AcroForm 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /Fields [] >>\nendobj\n' +
|
||||
'xref\n0 3\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000065 00000 n\n' +
|
||||
'trailer\n<< /Size 3 /Root 1 0 R >>\n' +
|
||||
'startxref\n100\n%%EOF'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(formPDF);
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
hasForm: !!result?.form,
|
||||
formFieldCount: result?.form?.fields?.length || 0,
|
||||
hasData: !!result?.data
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
processed: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(emptyFormFields.processed, 'PDF with empty form fields was processed');
|
||||
|
||||
// Test 9: Recovery attempts on zero-byte files
|
||||
const recoveryAttempts = await performanceTracker.measureAsync(
|
||||
'recovery-attempts-zero-byte',
|
||||
async () => {
|
||||
const corruptScenarios = [
|
||||
{
|
||||
name: 'no-header',
|
||||
content: Buffer.from('This is not a PDF')
|
||||
},
|
||||
{
|
||||
name: 'binary-garbage',
|
||||
content: Buffer.from([0xFF, 0xFE, 0xFD, 0xFC, 0x00, 0x01, 0x02, 0x03])
|
||||
},
|
||||
{
|
||||
name: 'html-instead',
|
||||
content: Buffer.from('<html><body>Not a PDF</body></html>')
|
||||
},
|
||||
{
|
||||
name: 'partial-header',
|
||||
content: Buffer.from('%PDF-')
|
||||
},
|
||||
{
|
||||
name: 'wrong-version',
|
||||
content: Buffer.from('%PDF-99.9\n%%EOF')
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const scenario of corruptScenarios) {
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(scenario.content, {
|
||||
attemptRecovery: true
|
||||
});
|
||||
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
recovered: !!result,
|
||||
hasAnyData: !!result?.xml || !!result?.attachments?.length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
recovered: false,
|
||||
errorMessage: error.message,
|
||||
recognized: error.message.includes('PDF') || error.message.includes('format')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
recoveryAttempts.forEach(result => {
|
||||
t.ok(!result.recovered, `Recovery should fail for ${result.scenario}`);
|
||||
t.ok(result.recognized, `Error should recognize invalid PDF format`);
|
||||
});
|
||||
|
||||
// Test 10: Batch processing with zero-byte PDFs
|
||||
const batchWithZeroBytes = await performanceTracker.measureAsync(
|
||||
'batch-processing-zero-byte',
|
||||
async () => {
|
||||
const batch = [
|
||||
{ name: 'normal', content: createValidPDF() },
|
||||
{ name: 'zero-byte', content: Buffer.alloc(0) },
|
||||
{ name: 'normal2', content: createValidPDF() },
|
||||
{ name: 'header-only', content: Buffer.from('%PDF-1.4') },
|
||||
{ name: 'normal3', content: createValidPDF() }
|
||||
];
|
||||
|
||||
const results = {
|
||||
total: batch.length,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const item of batch) {
|
||||
try {
|
||||
const result = await einvoice.extractFromPDF(item.content);
|
||||
|
||||
if (result?.xml || result?.attachments?.length) {
|
||||
results.successful++;
|
||||
} else {
|
||||
results.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
name: item.name,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
t.equal(batchWithZeroBytes.total,
|
||||
batchWithZeroBytes.successful + batchWithZeroBytes.failed + batchWithZeroBytes.skipped,
|
||||
'All batch items were processed');
|
||||
t.ok(batchWithZeroBytes.failed > 0, 'Some zero-byte PDFs failed as expected');
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Helper function to create a valid PDF with invoice attachment
|
||||
function createValidPDF(): Buffer {
|
||||
return Buffer.from(
|
||||
'%PDF-1.4\n' +
|
||||
'1 0 obj\n<< /Type /Catalog /Names 2 0 R >>\nendobj\n' +
|
||||
'2 0 obj\n<< /EmbeddedFiles 3 0 R >>\nendobj\n' +
|
||||
'3 0 obj\n<< /Names [(invoice.xml) 4 0 R] >>\nendobj\n' +
|
||||
'4 0 obj\n<< /Type /Filespec /F (invoice.xml) /EF << /F 5 0 R >> >>\nendobj\n' +
|
||||
'5 0 obj\n<< /Type /EmbeddedFile /Length 50 >>\nstream\n' +
|
||||
'<?xml version="1.0"?><Invoice><ID>TEST</ID></Invoice>\n' +
|
||||
'endstream\nendobj\n' +
|
||||
'xref\n0 6\n' +
|
||||
'0000000000 65535 f\n' +
|
||||
'0000000009 00000 n\n' +
|
||||
'0000000062 00000 n\n' +
|
||||
'0000000103 00000 n\n' +
|
||||
'0000000151 00000 n\n' +
|
||||
'0000000229 00000 n\n' +
|
||||
'trailer\n<< /Size 6 /Root 1 0 R >>\n' +
|
||||
'startxref\n350\n%%EOF'
|
||||
);
|
||||
}
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
@ -0,0 +1,540 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-06: Circular References');
|
||||
|
||||
tap.test('EDGE-06: Circular References - should handle circular reference scenarios', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: ID reference cycles in XML
|
||||
const idReferenceCycles = await performanceTracker.measureAsync(
|
||||
'id-reference-cycles',
|
||||
async () => {
|
||||
const circularXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>INV-001</ID>
|
||||
<RelatedInvoice idref="INV-002"/>
|
||||
<Items>
|
||||
<Item id="item1">
|
||||
<RelatedItem idref="item2"/>
|
||||
<Price>100</Price>
|
||||
</Item>
|
||||
<Item id="item2">
|
||||
<RelatedItem idref="item3"/>
|
||||
<Price>200</Price>
|
||||
</Item>
|
||||
<Item id="item3">
|
||||
<RelatedItem idref="item1"/>
|
||||
<Price>300</Price>
|
||||
</Item>
|
||||
</Items>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(circularXML);
|
||||
|
||||
// Try to resolve references
|
||||
const resolved = await einvoice.resolveReferences(parsed, {
|
||||
maxDepth: 10,
|
||||
detectCycles: true
|
||||
});
|
||||
|
||||
return {
|
||||
parsed: true,
|
||||
hasCircularRefs: resolved?.hasCircularReferences || false,
|
||||
cyclesDetected: resolved?.detectedCycles || [],
|
||||
resolutionStopped: resolved?.stoppedAtDepth || false
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
parsed: false,
|
||||
error: error.message,
|
||||
cycleError: error.message.includes('circular') || error.message.includes('cycle')
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(idReferenceCycles.parsed || idReferenceCycles.cycleError,
|
||||
'Circular ID references were handled');
|
||||
|
||||
// Test 2: Entity reference loops
|
||||
const entityReferenceLoops = await performanceTracker.measureAsync(
|
||||
'entity-reference-loops',
|
||||
async () => {
|
||||
const loopingEntities = [
|
||||
{
|
||||
name: 'direct-loop',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE Invoice [
|
||||
<!ENTITY a "&b;">
|
||||
<!ENTITY b "&a;">
|
||||
]>
|
||||
<Invoice>
|
||||
<Note>&a;</Note>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'indirect-loop',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE Invoice [
|
||||
<!ENTITY a "&b;">
|
||||
<!ENTITY b "&c;">
|
||||
<!ENTITY c "&a;">
|
||||
]>
|
||||
<Invoice>
|
||||
<Note>&a;</Note>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'self-reference',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE Invoice [
|
||||
<!ENTITY recursive "&recursive;">
|
||||
]>
|
||||
<Invoice>
|
||||
<Note>&recursive;</Note>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of loopingEntities) {
|
||||
try {
|
||||
await einvoice.parseXML(test.xml);
|
||||
|
||||
results.push({
|
||||
type: test.name,
|
||||
handled: true,
|
||||
method: 'parsed-without-expansion'
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
type: test.name,
|
||||
handled: true,
|
||||
method: 'rejected',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
entityReferenceLoops.forEach(result => {
|
||||
t.ok(result.handled, `Entity loop ${result.type} was handled`);
|
||||
});
|
||||
|
||||
// Test 3: Schema import cycles
|
||||
const schemaImportCycles = await performanceTracker.measureAsync(
|
||||
'schema-import-cycles',
|
||||
async () => {
|
||||
// Simulate schemas that import each other
|
||||
const schemas = {
|
||||
'schema1.xsd': `<?xml version="1.0"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:import schemaLocation="schema2.xsd"/>
|
||||
<xs:element name="Invoice" type="InvoiceType"/>
|
||||
</xs:schema>`,
|
||||
|
||||
'schema2.xsd': `<?xml version="1.0"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:import schemaLocation="schema3.xsd"/>
|
||||
<xs:complexType name="InvoiceType"/>
|
||||
</xs:schema>`,
|
||||
|
||||
'schema3.xsd': `<?xml version="1.0"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:import schemaLocation="schema1.xsd"/>
|
||||
</xs:schema>`
|
||||
};
|
||||
|
||||
try {
|
||||
const validation = await einvoice.validateWithSchemas(schemas, {
|
||||
maxImportDepth: 10,
|
||||
detectImportCycles: true
|
||||
});
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
cycleDetected: validation?.importCycleDetected || false,
|
||||
importChain: validation?.importChain || []
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
handled: true,
|
||||
error: error.message,
|
||||
isCycleError: error.message.includes('import') && error.message.includes('cycle')
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(schemaImportCycles.handled, 'Schema import cycles were handled');
|
||||
|
||||
// Test 4: Object graph cycles in parsed data
|
||||
const objectGraphCycles = await performanceTracker.measureAsync(
|
||||
'object-graph-cycles',
|
||||
async () => {
|
||||
// Create invoice with potential object cycles
|
||||
const invoice = {
|
||||
id: 'INV-001',
|
||||
items: [],
|
||||
parent: null
|
||||
};
|
||||
|
||||
const item1 = {
|
||||
id: 'ITEM-001',
|
||||
invoice: invoice,
|
||||
relatedItems: []
|
||||
};
|
||||
|
||||
const item2 = {
|
||||
id: 'ITEM-002',
|
||||
invoice: invoice,
|
||||
relatedItems: [item1]
|
||||
};
|
||||
|
||||
// Create circular reference
|
||||
item1.relatedItems.push(item2);
|
||||
invoice.items.push(item1, item2);
|
||||
invoice.parent = invoice; // Self-reference
|
||||
|
||||
try {
|
||||
// Try to serialize/process the circular structure
|
||||
const result = await einvoice.processInvoiceObject(invoice, {
|
||||
detectCycles: true,
|
||||
maxTraversalDepth: 100
|
||||
});
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
cyclesDetected: result?.cyclesFound || false,
|
||||
serializable: result?.canSerialize || false,
|
||||
method: result?.handlingMethod
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
handled: false,
|
||||
error: error.message,
|
||||
isCircularError: error.message.includes('circular') ||
|
||||
error.message.includes('Converting circular structure')
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(objectGraphCycles.handled || objectGraphCycles.isCircularError,
|
||||
'Object graph cycles were handled');
|
||||
|
||||
// Test 5: Namespace circular dependencies
|
||||
const namespaceCirularDeps = await performanceTracker.measureAsync(
|
||||
'namespace-circular-dependencies',
|
||||
async () => {
|
||||
const circularNamespaceXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ns1:Invoice xmlns:ns1="http://example.com/ns1"
|
||||
xmlns:ns2="http://example.com/ns2"
|
||||
xmlns:ns3="http://example.com/ns3">
|
||||
<ns1:Items>
|
||||
<ns2:Item ns3:ref="item1">
|
||||
<ns1:SubItem ns2:ref="item2"/>
|
||||
</ns2:Item>
|
||||
<ns3:Item ns1:ref="item2">
|
||||
<ns2:SubItem ns3:ref="item3"/>
|
||||
</ns3:Item>
|
||||
<ns1:Item ns2:ref="item3">
|
||||
<ns3:SubItem ns1:ref="item1"/>
|
||||
</ns1:Item>
|
||||
</ns1:Items>
|
||||
</ns1:Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(circularNamespaceXML);
|
||||
const analysis = await einvoice.analyzeNamespaceUsage(parsed);
|
||||
|
||||
return {
|
||||
parsed: true,
|
||||
namespaceCount: analysis?.namespaces?.length || 0,
|
||||
hasCrossReferences: analysis?.hasCrossNamespaceRefs || false,
|
||||
complexityScore: analysis?.complexityScore || 0
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
parsed: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(namespaceCirularDeps.parsed || namespaceCirularDeps.error,
|
||||
'Namespace circular dependencies were processed');
|
||||
|
||||
// Test 6: Include/Import cycles in documents
|
||||
const includeImportCycles = await performanceTracker.measureAsync(
|
||||
'include-import-cycles',
|
||||
async () => {
|
||||
const documents = {
|
||||
'main.xml': `<?xml version="1.0"?>
|
||||
<Invoice xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="part1.xml"/>
|
||||
</Invoice>`,
|
||||
|
||||
'part1.xml': `<?xml version="1.0"?>
|
||||
<InvoicePart xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="part2.xml"/>
|
||||
</InvoicePart>`,
|
||||
|
||||
'part2.xml': `<?xml version="1.0"?>
|
||||
<InvoicePart xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="main.xml"/>
|
||||
</InvoicePart>`
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await einvoice.processWithIncludes(documents['main.xml'], {
|
||||
resolveIncludes: true,
|
||||
maxIncludeDepth: 10,
|
||||
includeMap: documents
|
||||
});
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
includeDepthReached: result?.maxDepthReached || false,
|
||||
cycleDetected: result?.includeCycleDetected || false
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
processed: false,
|
||||
error: error.message,
|
||||
isIncludeError: error.message.includes('include') || error.message.includes('XInclude')
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(includeImportCycles.processed || includeImportCycles.isIncludeError,
|
||||
'Include cycles were handled');
|
||||
|
||||
// Test 7: Circular parent-child relationships
|
||||
const parentChildCircular = await performanceTracker.measureAsync(
|
||||
'parent-child-circular',
|
||||
async () => {
|
||||
// Test various parent-child circular scenarios
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'self-parent',
|
||||
xml: `<Invoice id="inv1" parent="inv1"><ID>001</ID></Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'mutual-parents',
|
||||
xml: `<Invoices>
|
||||
<Invoice id="inv1" parent="inv2"><ID>001</ID></Invoice>
|
||||
<Invoice id="inv2" parent="inv1"><ID>002</ID></Invoice>
|
||||
</Invoices>`
|
||||
},
|
||||
{
|
||||
name: 'chain-loop',
|
||||
xml: `<Invoices>
|
||||
<Invoice id="A" parent="B"><ID>A</ID></Invoice>
|
||||
<Invoice id="B" parent="C"><ID>B</ID></Invoice>
|
||||
<Invoice id="C" parent="A"><ID>C</ID></Invoice>
|
||||
</Invoices>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(scenario.xml);
|
||||
const hierarchy = await einvoice.buildHierarchy(parsed, {
|
||||
detectCircular: true
|
||||
});
|
||||
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
handled: true,
|
||||
isCircular: hierarchy?.hasCircularParentage || false,
|
||||
maxDepth: hierarchy?.maxDepth || 0
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
handled: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
parentChildCircular.forEach(result => {
|
||||
t.ok(result.handled || result.error,
|
||||
`Parent-child circular scenario ${result.scenario} was processed`);
|
||||
});
|
||||
|
||||
// Test 8: Circular calculations
|
||||
const circularCalculations = await performanceTracker.measureAsync(
|
||||
'circular-calculations',
|
||||
async () => {
|
||||
const calculationXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<Calculations>
|
||||
<Field name="subtotal" formula="=total-tax"/>
|
||||
<Field name="tax" formula="=subtotal*0.2"/>
|
||||
<Field name="total" formula="=subtotal+tax"/>
|
||||
</Calculations>
|
||||
<Items>
|
||||
<Item price="100" quantity="2"/>
|
||||
<Item price="50" quantity="3"/>
|
||||
</Items>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(calculationXML);
|
||||
const calculated = await einvoice.evaluateCalculations(parsed, {
|
||||
maxIterations: 10,
|
||||
detectCircular: true
|
||||
});
|
||||
|
||||
return {
|
||||
evaluated: true,
|
||||
hasCircularDependency: calculated?.circularDependency || false,
|
||||
resolvedValues: calculated?.resolved || {},
|
||||
iterations: calculated?.iterationsUsed || 0
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
evaluated: false,
|
||||
error: error.message,
|
||||
isCircularCalc: error.message.includes('circular') && error.message.includes('calculation')
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(circularCalculations.evaluated || circularCalculations.isCircularCalc,
|
||||
'Circular calculations were handled');
|
||||
|
||||
// Test 9: Memory safety with circular structures
|
||||
const memorySafetyCircular = await performanceTracker.measureAsync(
|
||||
'memory-safety-circular',
|
||||
async () => {
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
// Create a deeply circular structure
|
||||
const createCircularChain = (depth: number) => {
|
||||
const nodes = [];
|
||||
for (let i = 0; i < depth; i++) {
|
||||
nodes.push({ id: i, next: null, data: 'X'.repeat(1000) });
|
||||
}
|
||||
|
||||
// Link them circularly
|
||||
for (let i = 0; i < depth; i++) {
|
||||
nodes[i].next = nodes[(i + 1) % depth];
|
||||
}
|
||||
|
||||
return nodes[0];
|
||||
};
|
||||
|
||||
const results = {
|
||||
smallCircle: false,
|
||||
mediumCircle: false,
|
||||
largeCircle: false,
|
||||
memoryStable: true
|
||||
};
|
||||
|
||||
try {
|
||||
// Test increasingly large circular structures
|
||||
const small = createCircularChain(10);
|
||||
await einvoice.processCircularStructure(small);
|
||||
results.smallCircle = true;
|
||||
|
||||
const medium = createCircularChain(100);
|
||||
await einvoice.processCircularStructure(medium);
|
||||
results.mediumCircle = true;
|
||||
|
||||
const large = createCircularChain(1000);
|
||||
await einvoice.processCircularStructure(large);
|
||||
results.largeCircle = true;
|
||||
|
||||
const endMemory = process.memoryUsage();
|
||||
const memoryIncrease = endMemory.heapUsed - startMemory.heapUsed;
|
||||
|
||||
results.memoryStable = memoryIncrease < 100 * 1024 * 1024; // Less than 100MB
|
||||
|
||||
} catch (error) {
|
||||
// Expected for very large structures
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(memorySafetyCircular.smallCircle, 'Small circular structures handled safely');
|
||||
t.ok(memorySafetyCircular.memoryStable, 'Memory usage remained stable');
|
||||
|
||||
// Test 10: Format conversion with circular references
|
||||
const formatConversionCircular = await performanceTracker.measureAsync(
|
||||
'format-conversion-circular',
|
||||
async () => {
|
||||
// Create UBL invoice with circular references
|
||||
const ublWithCircular = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>CIRC-001</ID>
|
||||
<InvoiceReference>
|
||||
<ID>CIRC-001</ID> <!-- Self-reference -->
|
||||
</InvoiceReference>
|
||||
<OrderReference>
|
||||
<DocumentReference>
|
||||
<ID>ORDER-001</ID>
|
||||
<IssuerParty>
|
||||
<PartyReference>
|
||||
<ID>CIRC-001</ID> <!-- Circular reference back to invoice -->
|
||||
</PartyReference>
|
||||
</IssuerParty>
|
||||
</DocumentReference>
|
||||
</OrderReference>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
// Convert to CII
|
||||
const converted = await einvoice.convertFormat(ublWithCircular, 'cii', {
|
||||
handleCircularRefs: true,
|
||||
maxRefDepth: 5
|
||||
});
|
||||
|
||||
// Check if circular refs were handled
|
||||
const analysis = await einvoice.analyzeReferences(converted);
|
||||
|
||||
return {
|
||||
converted: true,
|
||||
circularRefsPreserved: analysis?.hasCircularRefs || false,
|
||||
refsFlattened: analysis?.refsFlattened || false,
|
||||
conversionMethod: analysis?.method
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
converted: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(formatConversionCircular.converted || formatConversionCircular.error,
|
||||
'Format conversion with circular refs was handled');
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
729
test/suite/einvoice_edge-cases/test.edge-07.max-field-lengths.ts
Normal file
729
test/suite/einvoice_edge-cases/test.edge-07.max-field-lengths.ts
Normal file
@ -0,0 +1,729 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-07: Maximum Field Lengths');
|
||||
|
||||
tap.test('EDGE-07: Maximum Field Lengths - should handle fields at maximum allowed lengths', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Standard field length limits
|
||||
const standardFieldLimits = await performanceTracker.measureAsync(
|
||||
'standard-field-limits',
|
||||
async () => {
|
||||
const fieldTests = [
|
||||
{ field: 'InvoiceID', maxLength: 200, standard: 'EN16931' },
|
||||
{ field: 'CustomerName', maxLength: 200, standard: 'EN16931' },
|
||||
{ field: 'Description', maxLength: 1000, standard: 'EN16931' },
|
||||
{ field: 'Note', maxLength: 5000, standard: 'EN16931' },
|
||||
{ field: 'Reference', maxLength: 200, standard: 'EN16931' },
|
||||
{ field: 'Email', maxLength: 254, standard: 'RFC5321' },
|
||||
{ field: 'Phone', maxLength: 30, standard: 'ITU-T' },
|
||||
{ field: 'PostalCode', maxLength: 20, standard: 'UPU' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of fieldTests) {
|
||||
// Test at max length
|
||||
const maxValue = 'X'.repeat(test.maxLength);
|
||||
const xml = createInvoiceWithField(test.field, maxValue);
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const validated = await einvoice.validate(parsed);
|
||||
|
||||
results.push({
|
||||
field: test.field,
|
||||
length: test.maxLength,
|
||||
parsed: true,
|
||||
valid: validated?.isValid || false,
|
||||
preserved: getFieldValue(parsed, test.field)?.length === test.maxLength
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
field: test.field,
|
||||
length: test.maxLength,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Test over max length
|
||||
const overValue = 'X'.repeat(test.maxLength + 1);
|
||||
const overXml = createInvoiceWithField(test.field, overValue);
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(overXml);
|
||||
const validated = await einvoice.validate(parsed);
|
||||
|
||||
results.push({
|
||||
field: test.field,
|
||||
length: test.maxLength + 1,
|
||||
parsed: true,
|
||||
valid: validated?.isValid || false,
|
||||
truncated: getFieldValue(parsed, test.field)?.length <= test.maxLength
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
field: test.field,
|
||||
length: test.maxLength + 1,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
standardFieldLimits.forEach(result => {
|
||||
if (result.length <= result.maxLength) {
|
||||
t.ok(result.valid, `Field ${result.field} at max length should be valid`);
|
||||
} else {
|
||||
t.notOk(result.valid, `Field ${result.field} over max length should be invalid`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 2: Unicode character length vs byte length
|
||||
const unicodeLengthTests = await performanceTracker.measureAsync(
|
||||
'unicode-length-vs-bytes',
|
||||
async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'ascii-only',
|
||||
char: 'A',
|
||||
bytesPerChar: 1
|
||||
},
|
||||
{
|
||||
name: 'latin-extended',
|
||||
char: 'ñ',
|
||||
bytesPerChar: 2
|
||||
},
|
||||
{
|
||||
name: 'chinese',
|
||||
char: '中',
|
||||
bytesPerChar: 3
|
||||
},
|
||||
{
|
||||
name: 'emoji',
|
||||
char: '😀',
|
||||
bytesPerChar: 4
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const maxChars = 100;
|
||||
|
||||
for (const test of testCases) {
|
||||
const value = test.char.repeat(maxChars);
|
||||
const byteLength = Buffer.from(value, 'utf8').length;
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>TEST</ID>
|
||||
<CustomerName>${value}</CustomerName>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const retrievedValue = parsed?.CustomerName || '';
|
||||
|
||||
results.push({
|
||||
type: test.name,
|
||||
charCount: value.length,
|
||||
byteCount: byteLength,
|
||||
expectedBytes: maxChars * test.bytesPerChar,
|
||||
preserved: retrievedValue === value,
|
||||
retrievedLength: retrievedValue.length,
|
||||
retrievedBytes: Buffer.from(retrievedValue, 'utf8').length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
type: test.name,
|
||||
charCount: value.length,
|
||||
byteCount: byteLength,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
unicodeLengthTests.forEach(result => {
|
||||
t.ok(result.preserved || result.error,
|
||||
`Unicode ${result.type} field should be handled correctly`);
|
||||
if (result.preserved) {
|
||||
t.equal(result.retrievedLength, result.charCount,
|
||||
`Character count should be preserved for ${result.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Format-specific field limits
|
||||
const formatSpecificLimits = await performanceTracker.measureAsync(
|
||||
'format-specific-limits',
|
||||
async () => {
|
||||
const formatLimits = [
|
||||
{
|
||||
format: 'ubl',
|
||||
fields: [
|
||||
{ name: 'ID', maxLength: 200 },
|
||||
{ name: 'Note', maxLength: 1000 },
|
||||
{ name: 'DocumentCurrencyCode', maxLength: 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
format: 'cii',
|
||||
fields: [
|
||||
{ name: 'ID', maxLength: 35 },
|
||||
{ name: 'Content', maxLength: 5000 },
|
||||
{ name: 'TypeCode', maxLength: 4 }
|
||||
]
|
||||
},
|
||||
{
|
||||
format: 'xrechnung',
|
||||
fields: [
|
||||
{ name: 'BT-1', maxLength: 16 }, // Invoice number
|
||||
{ name: 'BT-22', maxLength: 1000 }, // Note
|
||||
{ name: 'BT-5', maxLength: 3 } // Currency
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const format of formatLimits) {
|
||||
for (const field of format.fields) {
|
||||
const value = 'A'.repeat(field.maxLength);
|
||||
const invoice = createFormatSpecificInvoice(format.format, field.name, value);
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(invoice);
|
||||
const validated = await einvoice.validateFormat(parsed, format.format);
|
||||
|
||||
results.push({
|
||||
format: format.format,
|
||||
field: field.name,
|
||||
maxLength: field.maxLength,
|
||||
valid: validated?.isValid || false,
|
||||
compliant: validated?.formatCompliant || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
format: format.format,
|
||||
field: field.name,
|
||||
maxLength: field.maxLength,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
formatSpecificLimits.forEach(result => {
|
||||
t.ok(result.valid || result.error,
|
||||
`${result.format} field ${result.field} at max length was processed`);
|
||||
});
|
||||
|
||||
// Test 4: Extreme length edge cases
|
||||
const extremeLengthCases = await performanceTracker.measureAsync(
|
||||
'extreme-length-edge-cases',
|
||||
async () => {
|
||||
const extremeCases = [
|
||||
{ length: 0, name: 'empty' },
|
||||
{ length: 1, name: 'single-char' },
|
||||
{ length: 255, name: 'common-db-limit' },
|
||||
{ length: 65535, name: 'uint16-max' },
|
||||
{ length: 1000000, name: 'one-million' },
|
||||
{ length: 10000000, name: 'ten-million' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const testCase of extremeCases) {
|
||||
const value = testCase.length > 0 ? 'X'.repeat(testCase.length) : '';
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>EXTREME-${testCase.name}</ID>
|
||||
<LongField>${value}</LongField>
|
||||
</Invoice>`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.push({
|
||||
length: testCase.length,
|
||||
name: testCase.name,
|
||||
parsed: true,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMemory.heapUsed - startMemory.heapUsed,
|
||||
fieldPreserved: parsed?.LongField?.length === testCase.length
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
length: testCase.length,
|
||||
name: testCase.name,
|
||||
parsed: false,
|
||||
error: error.message,
|
||||
isLengthError: error.message.includes('length') || error.message.includes('size')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
extremeLengthCases.forEach(result => {
|
||||
if (result.length <= 65535) {
|
||||
t.ok(result.parsed, `Length ${result.name} should be handled`);
|
||||
} else {
|
||||
t.ok(!result.parsed || result.isLengthError,
|
||||
`Extreme length ${result.name} should be limited`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: Line item count limits
|
||||
const lineItemCountLimits = await performanceTracker.measureAsync(
|
||||
'line-item-count-limits',
|
||||
async () => {
|
||||
const itemCounts = [100, 1000, 9999, 10000, 99999];
|
||||
const results = [];
|
||||
|
||||
for (const count of itemCounts) {
|
||||
const invoice = createInvoiceWithManyItems(count);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(invoice);
|
||||
const itemsParsed = countItems(parsed);
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
results.push({
|
||||
requestedCount: count,
|
||||
parsedCount: itemsParsed,
|
||||
success: true,
|
||||
timeTaken: endTime - startTime,
|
||||
avgTimePerItem: (endTime - startTime) / count
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
requestedCount: count,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
lineItemCountLimits.forEach(result => {
|
||||
if (result.requestedCount <= 10000) {
|
||||
t.ok(result.success, `${result.requestedCount} line items should be supported`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: Attachment size limits
|
||||
const attachmentSizeLimits = await performanceTracker.measureAsync(
|
||||
'attachment-size-limits',
|
||||
async () => {
|
||||
const sizes = [
|
||||
{ size: 1024 * 1024, name: '1MB' },
|
||||
{ size: 10 * 1024 * 1024, name: '10MB' },
|
||||
{ size: 50 * 1024 * 1024, name: '50MB' },
|
||||
{ size: 100 * 1024 * 1024, name: '100MB' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of sizes) {
|
||||
const attachmentData = Buffer.alloc(test.size, 'A');
|
||||
const base64Data = attachmentData.toString('base64');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>ATT-TEST</ID>
|
||||
<Attachment>
|
||||
<EmbeddedDocumentBinaryObject mimeCode="application/pdf">
|
||||
${base64Data}
|
||||
</EmbeddedDocumentBinaryObject>
|
||||
</Attachment>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const attachment = extractAttachment(parsed);
|
||||
|
||||
results.push({
|
||||
size: test.name,
|
||||
bytes: test.size,
|
||||
parsed: true,
|
||||
attachmentPreserved: attachment?.length === test.size
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
size: test.name,
|
||||
bytes: test.size,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
attachmentSizeLimits.forEach(result => {
|
||||
if (result.bytes <= 50 * 1024 * 1024) {
|
||||
t.ok(result.parsed, `Attachment size ${result.size} should be supported`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7: Decimal precision limits
|
||||
const decimalPrecisionLimits = await performanceTracker.measureAsync(
|
||||
'decimal-precision-limits',
|
||||
async () => {
|
||||
const precisionTests = [
|
||||
{ decimals: 2, value: '12345678901234567890.12' },
|
||||
{ decimals: 4, value: '123456789012345678.1234' },
|
||||
{ decimals: 6, value: '1234567890123456.123456' },
|
||||
{ decimals: 10, value: '123456789012.1234567890' },
|
||||
{ decimals: 20, value: '12.12345678901234567890' },
|
||||
{ decimals: 30, value: '1.123456789012345678901234567890' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of precisionTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<TotalAmount currencyID="EUR">${test.value}</TotalAmount>
|
||||
<Items>
|
||||
<Item>
|
||||
<Price>${test.value}</Price>
|
||||
<Quantity>1</Quantity>
|
||||
</Item>
|
||||
</Items>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const amount = parsed?.TotalAmount;
|
||||
|
||||
// Check precision preservation
|
||||
const preserved = amount?.toString() === test.value;
|
||||
const rounded = amount?.toString() !== test.value;
|
||||
|
||||
results.push({
|
||||
decimals: test.decimals,
|
||||
originalValue: test.value,
|
||||
parsedValue: amount?.toString(),
|
||||
preserved,
|
||||
rounded
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
decimals: test.decimals,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
decimalPrecisionLimits.forEach(result => {
|
||||
t.ok(result.preserved || result.rounded,
|
||||
`Decimal precision ${result.decimals} should be handled`);
|
||||
});
|
||||
|
||||
// Test 8: Maximum nesting with field lengths
|
||||
const nestingWithLengths = await performanceTracker.measureAsync(
|
||||
'nesting-with-field-lengths',
|
||||
async () => {
|
||||
const createDeepStructure = (depth: number, fieldLength: number) => {
|
||||
let xml = '';
|
||||
const fieldValue = 'X'.repeat(fieldLength);
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
xml += `<Level${i}><Field${i}>${fieldValue}</Field${i}>`;
|
||||
}
|
||||
|
||||
xml += '<Core>Data</Core>';
|
||||
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
xml += `</Level${i}>`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
};
|
||||
|
||||
const tests = [
|
||||
{ depth: 10, fieldLength: 1000 },
|
||||
{ depth: 50, fieldLength: 100 },
|
||||
{ depth: 100, fieldLength: 10 },
|
||||
{ depth: 5, fieldLength: 10000 }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of tests) {
|
||||
const content = createDeepStructure(test.depth, test.fieldLength);
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>${content}</Invoice>`;
|
||||
|
||||
const totalDataSize = test.depth * test.fieldLength;
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const endTime = Date.now();
|
||||
|
||||
results.push({
|
||||
depth: test.depth,
|
||||
fieldLength: test.fieldLength,
|
||||
totalDataSize,
|
||||
parsed: true,
|
||||
timeTaken: endTime - startTime
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
depth: test.depth,
|
||||
fieldLength: test.fieldLength,
|
||||
totalDataSize,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
nestingWithLengths.forEach(result => {
|
||||
t.ok(result.parsed || result.error,
|
||||
`Nested structure with depth ${result.depth} and field length ${result.fieldLength} was processed`);
|
||||
});
|
||||
|
||||
// Test 9: Field truncation behavior
|
||||
const fieldTruncationBehavior = await performanceTracker.measureAsync(
|
||||
'field-truncation-behavior',
|
||||
async () => {
|
||||
const truncationTests = [
|
||||
{
|
||||
field: 'ID',
|
||||
maxLength: 50,
|
||||
testValue: 'A'.repeat(100),
|
||||
truncationType: 'hard'
|
||||
},
|
||||
{
|
||||
field: 'Note',
|
||||
maxLength: 1000,
|
||||
testValue: 'B'.repeat(2000),
|
||||
truncationType: 'soft'
|
||||
},
|
||||
{
|
||||
field: 'Email',
|
||||
maxLength: 254,
|
||||
testValue: 'x'.repeat(250) + '@test.com',
|
||||
truncationType: 'smart'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of truncationTests) {
|
||||
const xml = createInvoiceWithField(test.field, test.testValue);
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml, {
|
||||
truncateFields: true,
|
||||
truncationMode: test.truncationType
|
||||
});
|
||||
|
||||
const fieldValue = getFieldValue(parsed, test.field);
|
||||
|
||||
results.push({
|
||||
field: test.field,
|
||||
originalLength: test.testValue.length,
|
||||
truncatedLength: fieldValue?.length || 0,
|
||||
truncated: fieldValue?.length < test.testValue.length,
|
||||
withinLimit: fieldValue?.length <= test.maxLength,
|
||||
truncationType: test.truncationType
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
field: test.field,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
fieldTruncationBehavior.forEach(result => {
|
||||
if (result.truncated) {
|
||||
t.ok(result.withinLimit,
|
||||
`Field ${result.field} should be truncated to within limit`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 10: Performance impact of field lengths
|
||||
const performanceImpact = await performanceTracker.measureAsync(
|
||||
'field-length-performance-impact',
|
||||
async () => {
|
||||
const lengths = [10, 100, 1000, 10000, 100000];
|
||||
const results = [];
|
||||
|
||||
for (const length of lengths) {
|
||||
const iterations = 10;
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const value = 'X'.repeat(length);
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>PERF-TEST</ID>
|
||||
<Description>${value}</Description>
|
||||
<Note>${value}</Note>
|
||||
<CustomerName>${value}</CustomerName>
|
||||
</Invoice>`;
|
||||
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
await einvoice.parseXML(xml);
|
||||
} catch (error) {
|
||||
// Ignore errors for performance testing
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
times.push(Number(endTime - startTime) / 1000000); // Convert to ms
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
|
||||
results.push({
|
||||
fieldLength: length,
|
||||
avgParseTime: avgTime,
|
||||
timePerKB: avgTime / (length * 3 / 1024) // 3 fields
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Verify performance doesn't degrade exponentially
|
||||
const timeRatios = performanceImpact.map((r, i) =>
|
||||
i > 0 ? r.avgParseTime / performanceImpact[i-1].avgParseTime : 1
|
||||
);
|
||||
|
||||
timeRatios.forEach((ratio, i) => {
|
||||
if (i > 0) {
|
||||
t.ok(ratio < 15, `Performance scaling should be reasonable at length ${performanceImpact[i].fieldLength}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Helper function to create invoice with specific field
|
||||
function createInvoiceWithField(field: string, value: string): string {
|
||||
const fieldMap = {
|
||||
'InvoiceID': `<ID>${value}</ID>`,
|
||||
'CustomerName': `<CustomerName>${value}</CustomerName>`,
|
||||
'Description': `<Description>${value}</Description>`,
|
||||
'Note': `<Note>${value}</Note>`,
|
||||
'Reference': `<Reference>${value}</Reference>`,
|
||||
'Email': `<Email>${value}</Email>`,
|
||||
'Phone': `<Phone>${value}</Phone>`,
|
||||
'PostalCode': `<PostalCode>${value}</PostalCode>`
|
||||
};
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>TEST-001</ID>
|
||||
${fieldMap[field] || `<${field}>${value}</${field}>`}
|
||||
</Invoice>`;
|
||||
}
|
||||
|
||||
// Helper function to get field value from parsed object
|
||||
function getFieldValue(parsed: any, field: string): string | undefined {
|
||||
return parsed?.[field] || parsed?.Invoice?.[field];
|
||||
}
|
||||
|
||||
// Helper function to create format-specific invoice
|
||||
function createFormatSpecificInvoice(format: string, field: string, value: string): string {
|
||||
if (format === 'ubl') {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<${field}>${value}</${field}>
|
||||
</Invoice>`;
|
||||
} else if (format === 'cii') {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<rsm:${field}>${value}</rsm:${field}>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
}
|
||||
return createInvoiceWithField(field, value);
|
||||
}
|
||||
|
||||
// Helper function to create invoice with many items
|
||||
function createInvoiceWithManyItems(count: number): string {
|
||||
let items = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
items += `<Item><ID>${i}</ID><Price>10.00</Price></Item>`;
|
||||
}
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>MANY-ITEMS</ID>
|
||||
<Items>${items}</Items>
|
||||
</Invoice>`;
|
||||
}
|
||||
|
||||
// Helper function to count items
|
||||
function countItems(parsed: any): number {
|
||||
if (!parsed?.Items) return 0;
|
||||
if (Array.isArray(parsed.Items)) return parsed.Items.length;
|
||||
if (parsed.Items.Item) {
|
||||
return Array.isArray(parsed.Items.Item) ? parsed.Items.Item.length : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Helper function to extract attachment
|
||||
function extractAttachment(parsed: any): Buffer | null {
|
||||
const base64Data = parsed?.Attachment?.EmbeddedDocumentBinaryObject;
|
||||
if (base64Data) {
|
||||
return Buffer.from(base64Data, 'base64');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
715
test/suite/einvoice_edge-cases/test.edge-08.mixed-formats.ts
Normal file
715
test/suite/einvoice_edge-cases/test.edge-08.mixed-formats.ts
Normal file
@ -0,0 +1,715 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-08: Mixed Format Documents');
|
||||
|
||||
tap.test('EDGE-08: Mixed Format Documents - should handle documents with mixed or ambiguous formats', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Documents with elements from multiple standards
|
||||
const multiStandardElements = await performanceTracker.measureAsync(
|
||||
'multi-standard-elements',
|
||||
async () => {
|
||||
const mixedXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cii="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<!-- UBL elements -->
|
||||
<ubl:ID>MIXED-001</ubl:ID>
|
||||
<cbc:IssueDate>2024-01-15</cbc:IssueDate>
|
||||
|
||||
<!-- CII elements -->
|
||||
<cii:ExchangedDocument>
|
||||
<ram:ID>MIXED-001-CII</ram:ID>
|
||||
</cii:ExchangedDocument>
|
||||
|
||||
<!-- Custom elements -->
|
||||
<CustomField>Custom Value</CustomField>
|
||||
|
||||
<!-- Mix of both -->
|
||||
<LineItems>
|
||||
<ubl:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
</ubl:InvoiceLine>
|
||||
<cii:SupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>2</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
</cii:SupplyChainTradeLineItem>
|
||||
</LineItems>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const detection = await einvoice.detectFormat(mixedXML);
|
||||
const parsed = await einvoice.parseDocument(mixedXML);
|
||||
|
||||
return {
|
||||
detected: true,
|
||||
primaryFormat: detection?.format,
|
||||
confidence: detection?.confidence,
|
||||
mixedElements: detection?.mixedElements || [],
|
||||
standardsFound: detection?.detectedStandards || [],
|
||||
parsed: !!parsed
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
detected: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(multiStandardElements.detected, 'Multi-standard document was processed');
|
||||
t.ok(multiStandardElements.standardsFound?.length > 1, 'Multiple standards detected');
|
||||
|
||||
// Test 2: Namespace confusion
|
||||
const namespaceConfusion = await performanceTracker.measureAsync(
|
||||
'namespace-confusion',
|
||||
async () => {
|
||||
const confusedNamespaces = [
|
||||
{
|
||||
name: 'wrong-namespace-binding',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<!-- Using CII elements in UBL namespace -->
|
||||
<ExchangedDocument>
|
||||
<ID>CONFUSED-001</ID>
|
||||
</ExchangedDocument>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'conflicting-default-namespaces',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>UBL-001</ID>
|
||||
</Invoice>
|
||||
<Invoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocument>
|
||||
<ID>CII-001</ID>
|
||||
</ExchangedDocument>
|
||||
</Invoice>
|
||||
</root>`
|
||||
},
|
||||
{
|
||||
name: 'namespace-switching',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>START-UBL</ID>
|
||||
<Items xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<SupplyChainTradeLineItem>
|
||||
<LineID xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">1</LineID>
|
||||
</SupplyChainTradeLineItem>
|
||||
</Items>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of confusedNamespaces) {
|
||||
try {
|
||||
const detection = await einvoice.detectFormat(test.xml);
|
||||
const parsed = await einvoice.parseDocument(test.xml);
|
||||
const validation = await einvoice.validate(parsed);
|
||||
|
||||
results.push({
|
||||
scenario: test.name,
|
||||
detected: true,
|
||||
format: detection?.format,
|
||||
hasNamespaceIssues: detection?.namespaceIssues || false,
|
||||
valid: validation?.isValid || false,
|
||||
warnings: validation?.warnings || []
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: test.name,
|
||||
detected: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
namespaceConfusion.forEach(result => {
|
||||
t.ok(result.detected || result.error, `Namespace confusion ${result.scenario} was handled`);
|
||||
if (result.detected) {
|
||||
t.ok(result.hasNamespaceIssues || result.warnings.length > 0,
|
||||
'Namespace issues should be detected');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Hybrid PDF documents
|
||||
const hybridPDFDocuments = await performanceTracker.measureAsync(
|
||||
'hybrid-pdf-documents',
|
||||
async () => {
|
||||
const hybridScenarios = [
|
||||
{
|
||||
name: 'multiple-xml-attachments',
|
||||
description: 'PDF with both UBL and CII XML attachments'
|
||||
},
|
||||
{
|
||||
name: 'conflicting-metadata',
|
||||
description: 'PDF metadata says ZUGFeRD but contains Factur-X'
|
||||
},
|
||||
{
|
||||
name: 'mixed-version-attachments',
|
||||
description: 'PDF with ZUGFeRD v1 and v2 attachments'
|
||||
},
|
||||
{
|
||||
name: 'non-standard-attachment',
|
||||
description: 'PDF with standard XML plus custom format'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const scenario of hybridScenarios) {
|
||||
// Create mock hybrid PDF
|
||||
const hybridPDF = createHybridPDF(scenario.name);
|
||||
|
||||
try {
|
||||
const extraction = await einvoice.extractFromPDF(hybridPDF);
|
||||
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
extracted: true,
|
||||
attachmentCount: extraction?.attachments?.length || 0,
|
||||
formats: extraction?.detectedFormats || [],
|
||||
primaryFormat: extraction?.primaryFormat,
|
||||
hasConflicts: extraction?.hasFormatConflicts || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
extracted: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
hybridPDFDocuments.forEach(result => {
|
||||
t.ok(result.extracted || result.error,
|
||||
`Hybrid PDF ${result.scenario} was processed`);
|
||||
});
|
||||
|
||||
// Test 4: Schema version mixing
|
||||
const schemaVersionMixing = await performanceTracker.measureAsync(
|
||||
'schema-version-mixing',
|
||||
async () => {
|
||||
const versionMixes = [
|
||||
{
|
||||
name: 'ubl-version-mix',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
|
||||
xmlns:cbc1="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-1">
|
||||
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
|
||||
<cbc1:ID>OLD-STYLE-ID</cbc1:ID>
|
||||
<cbc:IssueDate>2024-01-15</cbc:IssueDate>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'zugferd-version-mix',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryDocument>
|
||||
<!-- ZUGFeRD 1.0 structure -->
|
||||
<rsm:SpecifiedExchangedDocumentContext>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:ferd:CrossIndustryDocument:invoice:1p0</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:SpecifiedExchangedDocumentContext>
|
||||
|
||||
<!-- ZUGFeRD 2.1 elements -->
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>MIXED-VERSION</ram:ID>
|
||||
</rsm:ExchangedDocument>
|
||||
</rsm:CrossIndustryDocument>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const mix of versionMixes) {
|
||||
try {
|
||||
const detection = await einvoice.detectFormat(mix.xml);
|
||||
const parsed = await einvoice.parseDocument(mix.xml);
|
||||
|
||||
results.push({
|
||||
scenario: mix.name,
|
||||
processed: true,
|
||||
detectedVersion: detection?.version,
|
||||
versionConflicts: detection?.versionConflicts || [],
|
||||
canMigrate: detection?.migrationPath !== undefined
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: mix.name,
|
||||
processed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
schemaVersionMixing.forEach(result => {
|
||||
t.ok(result.processed || result.error,
|
||||
`Version mix ${result.scenario} was handled`);
|
||||
});
|
||||
|
||||
// Test 5: Invalid format combinations
|
||||
const invalidFormatCombos = await performanceTracker.measureAsync(
|
||||
'invalid-format-combinations',
|
||||
async () => {
|
||||
const invalidCombos = [
|
||||
{
|
||||
name: 'ubl-with-cii-structure',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<!-- CII structure in UBL namespace -->
|
||||
<rsm:ExchangedDocumentContext>
|
||||
<ram:BusinessProcessSpecifiedDocumentContextParameter/>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>INVALID-001</ram:ID>
|
||||
</rsm:ExchangedDocument>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'html-invoice-hybrid',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html>
|
||||
<body>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>HTML-WRAPPED</ID>
|
||||
</Invoice>
|
||||
</body>
|
||||
</html>`
|
||||
},
|
||||
{
|
||||
name: 'json-xml-mix',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>JSON-MIX</ID>
|
||||
<JSONData>
|
||||
{"amount": 100, "currency": "EUR"}
|
||||
</JSONData>
|
||||
<XMLData>
|
||||
<Amount>100</Amount>
|
||||
<Currency>EUR</Currency>
|
||||
</XMLData>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const combo of invalidCombos) {
|
||||
try {
|
||||
const detection = await einvoice.detectFormat(combo.xml);
|
||||
const parsed = await einvoice.parseDocument(combo.xml);
|
||||
const validation = await einvoice.validate(parsed);
|
||||
|
||||
results.push({
|
||||
combo: combo.name,
|
||||
detected: !!detection,
|
||||
format: detection?.format || 'unknown',
|
||||
valid: validation?.isValid || false,
|
||||
recoverable: detection?.canRecover || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
combo: combo.name,
|
||||
detected: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
invalidFormatCombos.forEach(result => {
|
||||
t.notOk(result.valid, `Invalid combo ${result.combo} should not validate`);
|
||||
});
|
||||
|
||||
// Test 6: Partial format documents
|
||||
const partialFormatDocuments = await performanceTracker.measureAsync(
|
||||
'partial-format-documents',
|
||||
async () => {
|
||||
const partials = [
|
||||
{
|
||||
name: 'ubl-header-cii-body',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<cbc:ID>PARTIAL-001</cbc:ID>
|
||||
<cbc:IssueDate>2024-01-15</cbc:IssueDate>
|
||||
|
||||
<!-- Switch to CII for line items -->
|
||||
<IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>1</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
</IncludedSupplyChainTradeLineItem>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'incomplete-migration',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<!-- Old format -->
|
||||
<InvoiceNumber>OLD-001</InvoiceNumber>
|
||||
|
||||
<!-- New format -->
|
||||
<ID>NEW-001</ID>
|
||||
|
||||
<!-- Mixed date formats -->
|
||||
<InvoiceDate>2024-01-15</InvoiceDate>
|
||||
<IssueDate>2024-01-15T00:00:00</IssueDate>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const partial of partials) {
|
||||
try {
|
||||
const analysis = await einvoice.analyzeDocument(partial.xml);
|
||||
|
||||
results.push({
|
||||
scenario: partial.name,
|
||||
analyzed: true,
|
||||
completeness: analysis?.completeness || 0,
|
||||
missingElements: analysis?.missingElements || [],
|
||||
formatConsistency: analysis?.formatConsistency || 0,
|
||||
migrationNeeded: analysis?.requiresMigration || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: partial.name,
|
||||
analyzed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
partialFormatDocuments.forEach(result => {
|
||||
t.ok(result.analyzed || result.error,
|
||||
`Partial document ${result.scenario} was analyzed`);
|
||||
if (result.analyzed) {
|
||||
t.ok(result.formatConsistency < 100,
|
||||
'Format inconsistency should be detected');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7: Encoding format conflicts
|
||||
const encodingFormatConflicts = await performanceTracker.measureAsync(
|
||||
'encoding-format-conflicts',
|
||||
async () => {
|
||||
const encodingConflicts = [
|
||||
{
|
||||
name: 'utf8-with-utf16-content',
|
||||
declared: 'UTF-8',
|
||||
actual: 'UTF-16',
|
||||
content: Buffer.from('<?xml version="1.0" encoding="UTF-8"?><Invoice><ID>TEST</ID></Invoice>', 'utf16le')
|
||||
},
|
||||
{
|
||||
name: 'wrong-decimal-separator',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<!-- European format in US-style document -->
|
||||
<TotalAmount>1.234,56</TotalAmount>
|
||||
<TaxAmount>234.56</TaxAmount>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'date-format-mixing',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<Dates>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<DueDate>15/01/2024</DueDate>
|
||||
<DeliveryDate>01-15-2024</DeliveryDate>
|
||||
<PaymentDate>20240115</PaymentDate>
|
||||
</Dates>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const conflict of encodingConflicts) {
|
||||
try {
|
||||
let parseResult;
|
||||
|
||||
if (conflict.content) {
|
||||
parseResult = await einvoice.parseDocument(conflict.content);
|
||||
} else {
|
||||
parseResult = await einvoice.parseDocument(conflict.xml);
|
||||
}
|
||||
|
||||
const analysis = await einvoice.analyzeFormatConsistency(parseResult);
|
||||
|
||||
results.push({
|
||||
scenario: conflict.name,
|
||||
handled: true,
|
||||
encodingIssues: analysis?.encodingIssues || [],
|
||||
formatIssues: analysis?.formatIssues || [],
|
||||
normalized: analysis?.normalized || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: conflict.name,
|
||||
handled: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
encodingFormatConflicts.forEach(result => {
|
||||
t.ok(result.handled || result.error,
|
||||
`Encoding conflict ${result.scenario} was handled`);
|
||||
});
|
||||
|
||||
// Test 8: Format autodetection challenges
|
||||
const autodetectionChallenges = await performanceTracker.measureAsync(
|
||||
'format-autodetection-challenges',
|
||||
async () => {
|
||||
const challenges = [
|
||||
{
|
||||
name: 'minimal-structure',
|
||||
xml: '<Invoice><ID>123</ID></Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'generic-xml',
|
||||
xml: `<?xml version="1.0"?>
|
||||
<Document>
|
||||
<Header>
|
||||
<ID>DOC-001</ID>
|
||||
<Date>2024-01-15</Date>
|
||||
</Header>
|
||||
<Items>
|
||||
<Item>
|
||||
<Description>Product</Description>
|
||||
<Amount>100</Amount>
|
||||
</Item>
|
||||
</Items>
|
||||
</Document>`
|
||||
},
|
||||
{
|
||||
name: 'custom-namespace',
|
||||
xml: `<?xml version="1.0"?>
|
||||
<inv:Invoice xmlns:inv="http://custom.company.com/invoice">
|
||||
<inv:Number>INV-001</inv:Number>
|
||||
<inv:Total>1000</inv:Total>
|
||||
</inv:Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const challenge of challenges) {
|
||||
const detectionResult = await einvoice.detectFormat(challenge.xml);
|
||||
|
||||
results.push({
|
||||
scenario: challenge.name,
|
||||
format: detectionResult?.format || 'unknown',
|
||||
confidence: detectionResult?.confidence || 0,
|
||||
isGeneric: detectionResult?.isGeneric || false,
|
||||
suggestedFormats: detectionResult?.possibleFormats || []
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
autodetectionChallenges.forEach(result => {
|
||||
t.ok(result.confidence < 100 || result.isGeneric,
|
||||
`Challenge ${result.scenario} should have detection uncertainty`);
|
||||
});
|
||||
|
||||
// Test 9: Legacy format mixing
|
||||
const legacyFormatMixing = await performanceTracker.measureAsync(
|
||||
'legacy-format-mixing',
|
||||
async () => {
|
||||
const legacyMixes = [
|
||||
{
|
||||
name: 'edifact-xml-hybrid',
|
||||
content: `UNB+UNOC:3+SENDER+RECEIVER+240115:1200+1++INVOIC'
|
||||
<?xml version="1.0"?>
|
||||
<AdditionalData>
|
||||
<Invoice>
|
||||
<ID>HYBRID-001</ID>
|
||||
</Invoice>
|
||||
</AdditionalData>
|
||||
UNZ+1+1'`
|
||||
},
|
||||
{
|
||||
name: 'csv-xml-combination',
|
||||
content: `INVOICE_HEADER
|
||||
ID,Date,Amount
|
||||
INV-001,2024-01-15,1000.00
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<InvoiceDetails>
|
||||
<LineItems>
|
||||
<Item>Product A</Item>
|
||||
</LineItems>
|
||||
</InvoiceDetails>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const mix of legacyMixes) {
|
||||
try {
|
||||
const detection = await einvoice.detectFormat(mix.content);
|
||||
const extraction = await einvoice.extractStructuredData(mix.content);
|
||||
|
||||
results.push({
|
||||
scenario: mix.name,
|
||||
processed: true,
|
||||
formatsFound: detection?.multipleFormats || [],
|
||||
primaryFormat: detection?.primaryFormat,
|
||||
dataExtracted: !!extraction?.data
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: mix.name,
|
||||
processed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
legacyFormatMixing.forEach(result => {
|
||||
t.ok(result.processed || result.error,
|
||||
`Legacy mix ${result.scenario} was handled`);
|
||||
});
|
||||
|
||||
// Test 10: Format conversion conflicts
|
||||
const formatConversionConflicts = await performanceTracker.measureAsync(
|
||||
'format-conversion-conflicts',
|
||||
async () => {
|
||||
// Create invoice with format-specific features
|
||||
const sourceInvoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>CONVERT-001</ID>
|
||||
<!-- UBL-specific extension -->
|
||||
<UBLExtensions>
|
||||
<UBLExtension>
|
||||
<ExtensionContent>
|
||||
<CustomField>UBL-Only-Data</CustomField>
|
||||
</ExtensionContent>
|
||||
</UBLExtension>
|
||||
</UBLExtensions>
|
||||
|
||||
<!-- Format-specific calculation -->
|
||||
<AllowanceCharge>
|
||||
<ChargeIndicator>false</ChargeIndicator>
|
||||
<AllowanceChargeReason>Discount</AllowanceChargeReason>
|
||||
<Amount currencyID="EUR">50.00</Amount>
|
||||
</AllowanceCharge>
|
||||
</Invoice>`;
|
||||
|
||||
const targetFormats = ['cii', 'xrechnung', 'fatturapa'];
|
||||
const results = [];
|
||||
|
||||
for (const target of targetFormats) {
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(sourceInvoice, target);
|
||||
const analysis = await einvoice.analyzeConversion(sourceInvoice, converted);
|
||||
|
||||
results.push({
|
||||
targetFormat: target,
|
||||
converted: true,
|
||||
dataLoss: analysis?.dataLoss || [],
|
||||
unsupportedFeatures: analysis?.unsupportedFeatures || [],
|
||||
warnings: analysis?.warnings || []
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
targetFormat: target,
|
||||
converted: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
formatConversionConflicts.forEach(result => {
|
||||
t.ok(result.converted || result.error,
|
||||
`Conversion to ${result.targetFormat} was attempted`);
|
||||
if (result.converted) {
|
||||
t.ok(result.dataLoss.length > 0 || result.warnings.length > 0,
|
||||
'Format-specific features should cause warnings');
|
||||
}
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Helper function to create hybrid PDF
|
||||
function createHybridPDF(scenario: string): Buffer {
|
||||
// Simplified mock - in reality would create actual PDF structure
|
||||
const mockStructure = {
|
||||
'multiple-xml-attachments': {
|
||||
attachments: [
|
||||
{ name: 'invoice.ubl.xml', type: 'application/xml' },
|
||||
{ name: 'invoice.cii.xml', type: 'application/xml' }
|
||||
]
|
||||
},
|
||||
'conflicting-metadata': {
|
||||
metadata: { format: 'ZUGFeRD' },
|
||||
attachments: [{ name: 'facturx.xml', type: 'application/xml' }]
|
||||
},
|
||||
'mixed-version-attachments': {
|
||||
attachments: [
|
||||
{ name: 'zugferd_v1.xml', version: '1.0' },
|
||||
{ name: 'zugferd_v2.xml', version: '2.1' }
|
||||
]
|
||||
},
|
||||
'non-standard-attachment': {
|
||||
attachments: [
|
||||
{ name: 'invoice.xml', type: 'application/xml' },
|
||||
{ name: 'custom.json', type: 'application/json' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(mockStructure[scenario] || {}));
|
||||
}
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
804
test/suite/einvoice_edge-cases/test.edge-09.corrupted-zip.ts
Normal file
804
test/suite/einvoice_edge-cases/test.edge-09.corrupted-zip.ts
Normal file
@ -0,0 +1,804 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-09: Corrupted ZIP Containers');
|
||||
|
||||
tap.test('EDGE-09: Corrupted ZIP Containers - should handle corrupted ZIP/container files', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Invalid ZIP headers
|
||||
const invalidZipHeaders = await performanceTracker.measureAsync(
|
||||
'invalid-zip-headers',
|
||||
async () => {
|
||||
const corruptHeaders = [
|
||||
{
|
||||
name: 'wrong-magic-bytes',
|
||||
data: Buffer.from('NOTAZIP\x00\x00\x00\x00'),
|
||||
description: 'Invalid ZIP signature'
|
||||
},
|
||||
{
|
||||
name: 'partial-header',
|
||||
data: Buffer.from('PK\x03'),
|
||||
description: 'Incomplete ZIP header'
|
||||
},
|
||||
{
|
||||
name: 'corrupted-local-header',
|
||||
data: Buffer.concat([
|
||||
Buffer.from('PK\x03\x04'), // Local file header signature
|
||||
Buffer.from([0xFF, 0xFF, 0xFF, 0xFF]), // Corrupted version/flags
|
||||
Buffer.alloc(20, 0) // Rest of header
|
||||
]),
|
||||
description: 'Corrupted local file header'
|
||||
},
|
||||
{
|
||||
name: 'missing-central-directory',
|
||||
data: Buffer.concat([
|
||||
Buffer.from('PK\x03\x04'), // Local file header
|
||||
Buffer.alloc(26, 0), // Header data
|
||||
Buffer.from('PK\x07\x08'), // Data descriptor
|
||||
Buffer.alloc(12, 0), // Descriptor data
|
||||
// Missing central directory
|
||||
]),
|
||||
description: 'Missing central directory'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const corrupt of corruptHeaders) {
|
||||
try {
|
||||
const extracted = await einvoice.extractFromContainer(corrupt.data);
|
||||
|
||||
results.push({
|
||||
type: corrupt.name,
|
||||
recovered: !!extracted,
|
||||
filesExtracted: extracted?.files?.length || 0,
|
||||
error: null
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
type: corrupt.name,
|
||||
recovered: false,
|
||||
error: error.message,
|
||||
isZipError: error.message.toLowerCase().includes('zip') ||
|
||||
error.message.toLowerCase().includes('archive')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
invalidZipHeaders.forEach(result => {
|
||||
t.ok(!result.recovered || result.isZipError,
|
||||
`Invalid header ${result.type} should fail or be detected`);
|
||||
});
|
||||
|
||||
// Test 2: Truncated ZIP files
|
||||
const truncatedZipFiles = await performanceTracker.measureAsync(
|
||||
'truncated-zip-files',
|
||||
async () => {
|
||||
// Create a valid ZIP structure and truncate at different points
|
||||
const validZip = createValidZipStructure();
|
||||
|
||||
const truncationPoints = [
|
||||
{ point: 10, name: 'header-truncated' },
|
||||
{ point: 50, name: 'file-data-truncated' },
|
||||
{ point: validZip.length - 50, name: 'directory-truncated' },
|
||||
{ point: validZip.length - 10, name: 'eocd-truncated' },
|
||||
{ point: validZip.length - 1, name: 'last-byte-missing' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const truncation of truncationPoints) {
|
||||
const truncated = validZip.slice(0, truncation.point);
|
||||
|
||||
try {
|
||||
const recovery = await einvoice.recoverFromCorruptedZip(truncated, {
|
||||
attemptPartialRecovery: true
|
||||
});
|
||||
|
||||
results.push({
|
||||
truncation: truncation.name,
|
||||
size: truncated.length,
|
||||
recovered: recovery?.success || false,
|
||||
filesRecovered: recovery?.recoveredFiles || 0,
|
||||
dataRecovered: recovery?.recoveredBytes || 0
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
truncation: truncation.name,
|
||||
size: truncated.length,
|
||||
recovered: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
truncatedZipFiles.forEach(result => {
|
||||
t.ok(result.recovered === false || result.filesRecovered < 1,
|
||||
`Truncated ZIP at ${result.truncation} should have limited recovery`);
|
||||
});
|
||||
|
||||
// Test 3: CRC errors
|
||||
const crcErrors = await performanceTracker.measureAsync(
|
||||
'crc-checksum-errors',
|
||||
async () => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'single-bit-flip',
|
||||
corruption: (data: Buffer) => {
|
||||
const copy = Buffer.from(data);
|
||||
// Flip a bit in the compressed data
|
||||
if (copy.length > 100) {
|
||||
copy[100] ^= 0x01;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'data-corruption',
|
||||
corruption: (data: Buffer) => {
|
||||
const copy = Buffer.from(data);
|
||||
// Corrupt a chunk of data
|
||||
for (let i = 50; i < Math.min(100, copy.length); i++) {
|
||||
copy[i] = 0xFF;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wrong-crc-stored',
|
||||
corruption: (data: Buffer) => {
|
||||
const copy = Buffer.from(data);
|
||||
// Find and corrupt CRC values
|
||||
const crcOffset = findCRCOffset(copy);
|
||||
if (crcOffset > 0) {
|
||||
copy.writeUInt32LE(0xDEADBEEF, crcOffset);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const validZip = createZipWithInvoice();
|
||||
const corrupted = scenario.corruption(validZip);
|
||||
|
||||
try {
|
||||
const extraction = await einvoice.extractFromContainer(corrupted, {
|
||||
ignoreCRCErrors: false
|
||||
});
|
||||
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
extracted: true,
|
||||
crcValidated: extraction?.crcValid || false,
|
||||
dataIntegrity: extraction?.integrityCheck || 'unknown'
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
extracted: false,
|
||||
error: error.message,
|
||||
isCRCError: error.message.toLowerCase().includes('crc') ||
|
||||
error.message.toLowerCase().includes('checksum')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
crcErrors.forEach(result => {
|
||||
t.ok(!result.extracted || !result.crcValidated || result.isCRCError,
|
||||
`CRC error ${result.scenario} should be detected`);
|
||||
});
|
||||
|
||||
// Test 4: Compression method issues
|
||||
const compressionMethodIssues = await performanceTracker.measureAsync(
|
||||
'compression-method-issues',
|
||||
async () => {
|
||||
const compressionTests = [
|
||||
{
|
||||
name: 'unsupported-method',
|
||||
method: 99, // Invalid compression method
|
||||
description: 'Unknown compression algorithm'
|
||||
},
|
||||
{
|
||||
name: 'store-but-compressed',
|
||||
method: 0, // Store (no compression)
|
||||
compressed: true,
|
||||
description: 'Stored method but data is compressed'
|
||||
},
|
||||
{
|
||||
name: 'deflate-corrupted',
|
||||
method: 8, // Deflate
|
||||
corrupted: true,
|
||||
description: 'Deflate stream corrupted'
|
||||
},
|
||||
{
|
||||
name: 'bzip2-in-zip',
|
||||
method: 12, // Bzip2 (not standard in ZIP)
|
||||
description: 'Non-standard compression method'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of compressionTests) {
|
||||
const zipData = createZipWithCompressionMethod(test.method, test);
|
||||
|
||||
try {
|
||||
const extracted = await einvoice.extractFromContainer(zipData);
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
method: test.method,
|
||||
extracted: true,
|
||||
filesFound: extracted?.files?.length || 0,
|
||||
decompressed: extracted?.decompressed || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
method: test.method,
|
||||
extracted: false,
|
||||
error: error.message,
|
||||
isCompressionError: error.message.includes('compress') ||
|
||||
error.message.includes('method')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
compressionMethodIssues.forEach(result => {
|
||||
if (result.method === 0 || result.method === 8) {
|
||||
t.ok(result.extracted || result.isCompressionError,
|
||||
`Standard compression ${result.test} should be handled`);
|
||||
} else {
|
||||
t.notOk(result.extracted,
|
||||
`Non-standard compression ${result.test} should fail`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: Nested/recursive ZIP bombs
|
||||
const nestedZipBombs = await performanceTracker.measureAsync(
|
||||
'nested-zip-bombs',
|
||||
async () => {
|
||||
const bombTypes = [
|
||||
{
|
||||
name: 'deep-nesting',
|
||||
depth: 10,
|
||||
description: 'ZIP within ZIP, 10 levels deep'
|
||||
},
|
||||
{
|
||||
name: 'exponential-expansion',
|
||||
copies: 10,
|
||||
description: 'Each level contains 10 copies'
|
||||
},
|
||||
{
|
||||
name: 'circular-reference',
|
||||
circular: true,
|
||||
description: 'ZIP contains itself'
|
||||
},
|
||||
{
|
||||
name: 'compression-ratio-bomb',
|
||||
ratio: 1000,
|
||||
description: 'Extreme compression ratio'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const bomb of bombTypes) {
|
||||
const bombZip = createZipBomb(bomb);
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
try {
|
||||
const extraction = await einvoice.extractFromContainer(bombZip, {
|
||||
maxDepth: 5,
|
||||
maxExpandedSize: 100 * 1024 * 1024, // 100MB limit
|
||||
maxFiles: 1000
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.push({
|
||||
type: bomb.name,
|
||||
handled: true,
|
||||
timeTaken: endTime - startTime,
|
||||
memoryUsed: endMemory.heapUsed - startMemory.heapUsed,
|
||||
depthReached: extraction?.maxDepth || 0,
|
||||
stopped: extraction?.limitReached || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
type: bomb.name,
|
||||
handled: true,
|
||||
prevented: true,
|
||||
error: error.message,
|
||||
isBombDetected: error.message.includes('bomb') ||
|
||||
error.message.includes('depth') ||
|
||||
error.message.includes('limit')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
nestedZipBombs.forEach(result => {
|
||||
t.ok(result.prevented || result.stopped,
|
||||
`ZIP bomb ${result.type} should be prevented or limited`);
|
||||
});
|
||||
|
||||
// Test 6: Character encoding in filenames
|
||||
const filenameEncodingIssues = await performanceTracker.measureAsync(
|
||||
'filename-encoding-issues',
|
||||
async () => {
|
||||
const encodingTests = [
|
||||
{
|
||||
name: 'utf8-bom-filename',
|
||||
filename: '\uFEFFファイル.xml',
|
||||
encoding: 'utf8'
|
||||
},
|
||||
{
|
||||
name: 'cp437-extended',
|
||||
filename: 'Ñoño_español.xml',
|
||||
encoding: 'cp437'
|
||||
},
|
||||
{
|
||||
name: 'mixed-encoding',
|
||||
filename: 'Test_文件_файл.xml',
|
||||
encoding: 'mixed'
|
||||
},
|
||||
{
|
||||
name: 'null-bytes',
|
||||
filename: 'file\x00.xml',
|
||||
encoding: 'binary'
|
||||
},
|
||||
{
|
||||
name: 'path-traversal',
|
||||
filename: '../../../etc/passwd',
|
||||
encoding: 'ascii'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of encodingTests) {
|
||||
const zipData = createZipWithFilename(test.filename, test.encoding);
|
||||
|
||||
try {
|
||||
const extracted = await einvoice.extractFromContainer(zipData);
|
||||
const files = extracted?.files || [];
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
extracted: true,
|
||||
fileCount: files.length,
|
||||
filenamePreserved: files.some(f => f.name === test.filename),
|
||||
filenameNormalized: files[0]?.name || null,
|
||||
securityCheck: !files.some(f => f.name.includes('..'))
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
extracted: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
filenameEncodingIssues.forEach(result => {
|
||||
t.ok(result.securityCheck,
|
||||
`Filename ${result.test} should pass security checks`);
|
||||
});
|
||||
|
||||
// Test 7: Factur-X/ZUGFeRD specific corruptions
|
||||
const facturXCorruptions = await performanceTracker.measureAsync(
|
||||
'facturx-zugferd-corruptions',
|
||||
async () => {
|
||||
const corruptionTypes = [
|
||||
{
|
||||
name: 'missing-metadata',
|
||||
description: 'PDF/A-3 without required metadata'
|
||||
},
|
||||
{
|
||||
name: 'wrong-attachment-relationship',
|
||||
description: 'XML not marked as Alternative'
|
||||
},
|
||||
{
|
||||
name: 'multiple-xml-versions',
|
||||
description: 'Both Factur-X and ZUGFeRD XML present'
|
||||
},
|
||||
{
|
||||
name: 'corrupted-xml-stream',
|
||||
description: 'XML attachment stream corrupted'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const corruption of corruptionTypes) {
|
||||
const corruptedPDF = createCorruptedFacturX(corruption.name);
|
||||
|
||||
try {
|
||||
const extraction = await einvoice.extractFromPDF(corruptedPDF);
|
||||
|
||||
results.push({
|
||||
corruption: corruption.name,
|
||||
extracted: !!extraction,
|
||||
hasValidXML: extraction?.xml && isValidXML(extraction.xml),
|
||||
hasMetadata: !!extraction?.metadata,
|
||||
conformance: extraction?.conformanceLevel || 'unknown'
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
corruption: corruption.name,
|
||||
extracted: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
facturXCorruptions.forEach(result => {
|
||||
t.ok(result.extracted || result.error,
|
||||
`Factur-X corruption ${result.corruption} was handled`);
|
||||
});
|
||||
|
||||
// Test 8: Recovery strategies
|
||||
const recoveryStrategies = await performanceTracker.measureAsync(
|
||||
'zip-recovery-strategies',
|
||||
async () => {
|
||||
const strategies = [
|
||||
{
|
||||
name: 'scan-for-headers',
|
||||
description: 'Scan for local file headers'
|
||||
},
|
||||
{
|
||||
name: 'reconstruct-central-dir',
|
||||
description: 'Rebuild central directory'
|
||||
},
|
||||
{
|
||||
name: 'raw-deflate-extraction',
|
||||
description: 'Extract raw deflate streams'
|
||||
},
|
||||
{
|
||||
name: 'pattern-matching',
|
||||
description: 'Find XML by pattern matching'
|
||||
}
|
||||
];
|
||||
|
||||
const corruptedZip = createSeverelyCorruptedZip();
|
||||
const results = [];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
const recovered = await einvoice.attemptZipRecovery(corruptedZip, {
|
||||
strategy: strategy.name
|
||||
});
|
||||
|
||||
results.push({
|
||||
strategy: strategy.name,
|
||||
success: recovered?.success || false,
|
||||
filesRecovered: recovered?.files?.length || 0,
|
||||
xmlFound: recovered?.files?.some(f => f.name.endsWith('.xml')) || false,
|
||||
confidence: recovered?.confidence || 0
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
strategy: strategy.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
recoveryStrategies.forEach(result => {
|
||||
t.ok(result.success || result.error,
|
||||
`Recovery strategy ${result.strategy} was attempted`);
|
||||
});
|
||||
|
||||
// Test 9: Multi-part archive issues
|
||||
const multiPartArchiveIssues = await performanceTracker.measureAsync(
|
||||
'multi-part-archive-issues',
|
||||
async () => {
|
||||
const multiPartTests = [
|
||||
{
|
||||
name: 'missing-parts',
|
||||
parts: ['part1.zip', null, 'part3.zip'],
|
||||
description: 'Missing middle part'
|
||||
},
|
||||
{
|
||||
name: 'wrong-order',
|
||||
parts: ['part3.zip', 'part1.zip', 'part2.zip'],
|
||||
description: 'Parts in wrong order'
|
||||
},
|
||||
{
|
||||
name: 'mixed-formats',
|
||||
parts: ['part1.zip', 'part2.rar', 'part3.zip'],
|
||||
description: 'Different archive formats'
|
||||
},
|
||||
{
|
||||
name: 'size-mismatch',
|
||||
parts: createMismatchedParts(),
|
||||
description: 'Part sizes do not match'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of multiPartTests) {
|
||||
try {
|
||||
const assembled = await einvoice.assembleMultiPartArchive(test.parts);
|
||||
const extracted = await einvoice.extractFromContainer(assembled);
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
assembled: true,
|
||||
extracted: !!extracted,
|
||||
complete: extracted?.isComplete || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
assembled: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
multiPartArchiveIssues.forEach(result => {
|
||||
t.ok(!result.assembled || !result.complete,
|
||||
`Multi-part issue ${result.test} should cause problems`);
|
||||
});
|
||||
|
||||
// Test 10: Performance with corrupted files
|
||||
const corruptedPerformance = await performanceTracker.measureAsync(
|
||||
'corrupted-file-performance',
|
||||
async () => {
|
||||
const sizes = [
|
||||
{ size: 1024, name: '1KB' },
|
||||
{ size: 1024 * 1024, name: '1MB' },
|
||||
{ size: 10 * 1024 * 1024, name: '10MB' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const sizeTest of sizes) {
|
||||
// Create corrupted file of specific size
|
||||
const corrupted = createCorruptedZipOfSize(sizeTest.size);
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeout = 10000; // 10 second timeout
|
||||
|
||||
try {
|
||||
const extractPromise = einvoice.extractFromContainer(corrupted);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), timeout)
|
||||
);
|
||||
|
||||
await Promise.race([extractPromise, timeoutPromise]);
|
||||
|
||||
const timeTaken = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
size: sizeTest.name,
|
||||
completed: true,
|
||||
timeTaken,
|
||||
timedOut: false
|
||||
});
|
||||
} catch (error) {
|
||||
const timeTaken = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
size: sizeTest.name,
|
||||
completed: false,
|
||||
timeTaken,
|
||||
timedOut: error.message === 'Timeout',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
corruptedPerformance.forEach(result => {
|
||||
t.ok(!result.timedOut,
|
||||
`Corrupted file ${result.size} should not cause timeout`);
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
function createValidZipStructure(): Buffer {
|
||||
// Simplified ZIP structure
|
||||
const parts = [];
|
||||
|
||||
// Local file header
|
||||
parts.push(Buffer.from('PK\x03\x04')); // Signature
|
||||
parts.push(Buffer.alloc(26, 0)); // Header fields
|
||||
parts.push(Buffer.from('test.xml')); // Filename
|
||||
parts.push(Buffer.from('<Invoice><ID>123</ID></Invoice>')); // File data
|
||||
|
||||
// Central directory
|
||||
parts.push(Buffer.from('PK\x01\x02')); // Signature
|
||||
parts.push(Buffer.alloc(42, 0)); // Header fields
|
||||
parts.push(Buffer.from('test.xml')); // Filename
|
||||
|
||||
// End of central directory
|
||||
parts.push(Buffer.from('PK\x05\x06')); // Signature
|
||||
parts.push(Buffer.alloc(18, 0)); // EOCD fields
|
||||
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function createZipWithInvoice(): Buffer {
|
||||
// Create a simple ZIP with invoice XML
|
||||
return createValidZipStructure();
|
||||
}
|
||||
|
||||
function findCRCOffset(data: Buffer): number {
|
||||
// Find CRC32 field in ZIP structure
|
||||
const sig = Buffer.from('PK\x03\x04');
|
||||
const idx = data.indexOf(sig);
|
||||
if (idx >= 0) {
|
||||
return idx + 14; // CRC32 offset in local file header
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function createZipWithCompressionMethod(method: number, options: any): Buffer {
|
||||
const parts = [];
|
||||
|
||||
// Local file header with specific compression method
|
||||
parts.push(Buffer.from('PK\x03\x04'));
|
||||
const header = Buffer.alloc(26, 0);
|
||||
header.writeUInt16LE(method, 8); // Compression method
|
||||
parts.push(header);
|
||||
parts.push(Buffer.from('invoice.xml'));
|
||||
|
||||
// Add compressed or uncompressed data based on method
|
||||
if (options.corrupted) {
|
||||
parts.push(Buffer.from([0xFF, 0xFE, 0xFD])); // Invalid deflate stream
|
||||
} else if (method === 0) {
|
||||
parts.push(Buffer.from('<Invoice/>'));
|
||||
} else {
|
||||
parts.push(Buffer.from([0x78, 0x9C])); // Deflate header
|
||||
parts.push(Buffer.alloc(10, 0)); // Compressed data
|
||||
}
|
||||
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function createZipBomb(config: any): Buffer {
|
||||
// Create various types of ZIP bombs
|
||||
if (config.circular) {
|
||||
// Create a ZIP that references itself
|
||||
return Buffer.from('PK...[circular reference]...');
|
||||
} else if (config.depth) {
|
||||
// Create nested ZIPs
|
||||
let zip = Buffer.from('<Invoice/>');
|
||||
for (let i = 0; i < config.depth; i++) {
|
||||
zip = wrapInZip(zip, `level${i}.zip`);
|
||||
}
|
||||
return zip;
|
||||
}
|
||||
return Buffer.from('PK');
|
||||
}
|
||||
|
||||
function wrapInZip(content: Buffer, filename: string): Buffer {
|
||||
// Wrap content in a ZIP file
|
||||
return Buffer.concat([
|
||||
Buffer.from('PK\x03\x04'),
|
||||
Buffer.alloc(26, 0),
|
||||
Buffer.from(filename),
|
||||
content
|
||||
]);
|
||||
}
|
||||
|
||||
function createZipWithFilename(filename: string, encoding: string): Buffer {
|
||||
const parts = [];
|
||||
|
||||
parts.push(Buffer.from('PK\x03\x04'));
|
||||
const header = Buffer.alloc(26, 0);
|
||||
|
||||
// Set filename length
|
||||
const filenameBuffer = Buffer.from(filename, encoding === 'binary' ? 'binary' : 'utf8');
|
||||
header.writeUInt16LE(filenameBuffer.length, 24);
|
||||
|
||||
parts.push(header);
|
||||
parts.push(filenameBuffer);
|
||||
parts.push(Buffer.from('<Invoice/>'));
|
||||
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function createCorruptedFacturX(type: string): Buffer {
|
||||
// Create corrupted Factur-X/ZUGFeRD PDFs
|
||||
const mockPDF = Buffer.from('%PDF-1.4\n...');
|
||||
return mockPDF;
|
||||
}
|
||||
|
||||
function createSeverelyCorruptedZip(): Buffer {
|
||||
// Create a severely corrupted ZIP for recovery testing
|
||||
const data = Buffer.alloc(1024);
|
||||
data.fill(0xFF);
|
||||
// Add some ZIP-like signatures at random positions
|
||||
data.write('PK\x03\x04', 100);
|
||||
data.write('<Invoice', 200);
|
||||
data.write('</Invoice>', 300);
|
||||
return data;
|
||||
}
|
||||
|
||||
function createMismatchedParts(): Buffer[] {
|
||||
return [
|
||||
Buffer.alloc(1000, 1),
|
||||
Buffer.alloc(500, 2),
|
||||
Buffer.alloc(1500, 3)
|
||||
];
|
||||
}
|
||||
|
||||
function createCorruptedZipOfSize(size: number): Buffer {
|
||||
const data = Buffer.alloc(size);
|
||||
// Fill with random data
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
data.writeUInt32LE(Math.random() * 0xFFFFFFFF, i);
|
||||
}
|
||||
// Add ZIP signature at start
|
||||
data.write('PK\x03\x04', 0);
|
||||
return data;
|
||||
}
|
||||
|
||||
function isValidXML(content: string): boolean {
|
||||
try {
|
||||
// Simple XML validation check
|
||||
return content.includes('<?xml') && content.includes('>');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
695
test/suite/einvoice_edge-cases/test.edge-10.timezone-edges.ts
Normal file
695
test/suite/einvoice_edge-cases/test.edge-10.timezone-edges.ts
Normal file
@ -0,0 +1,695 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('EDGE-10: Time Zone Edge Cases');
|
||||
|
||||
tap.test('EDGE-10: Time Zone Edge Cases - should handle complex timezone scenarios', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: Date/time across timezone boundaries
|
||||
const timezoneBoundaries = await performanceTracker.measureAsync(
|
||||
'timezone-boundary-crossing',
|
||||
async () => {
|
||||
const boundaryTests = [
|
||||
{
|
||||
name: 'midnight-utc',
|
||||
dateTime: '2024-01-15T00:00:00Z',
|
||||
timezone: 'UTC',
|
||||
expectedLocal: '2024-01-15T00:00:00'
|
||||
},
|
||||
{
|
||||
name: 'midnight-cross-positive',
|
||||
dateTime: '2024-01-15T23:59:59+12:00',
|
||||
timezone: 'Pacific/Auckland',
|
||||
expectedUTC: '2024-01-15T11:59:59Z'
|
||||
},
|
||||
{
|
||||
name: 'midnight-cross-negative',
|
||||
dateTime: '2024-01-15T00:00:00-11:00',
|
||||
timezone: 'Pacific/Midway',
|
||||
expectedUTC: '2024-01-15T11:00:00Z'
|
||||
},
|
||||
{
|
||||
name: 'date-line-crossing',
|
||||
dateTime: '2024-01-15T12:00:00+14:00',
|
||||
timezone: 'Pacific/Kiritimati',
|
||||
expectedUTC: '2024-01-14T22:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of boundaryTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>TZ-TEST-001</ID>
|
||||
<IssueDate>${test.dateTime}</IssueDate>
|
||||
<DueDateTime>${test.dateTime}</DueDateTime>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const dates = await einvoice.normalizeDates(parsed, {
|
||||
targetTimezone: test.timezone
|
||||
});
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
parsed: true,
|
||||
originalDateTime: test.dateTime,
|
||||
normalizedDate: dates?.IssueDate,
|
||||
isDatePreserved: dates?.dateIntegrity || false,
|
||||
crossesDateBoundary: dates?.crossesDateLine || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
timezoneBoundaries.forEach(result => {
|
||||
t.ok(result.parsed, `Timezone boundary ${result.test} should be handled`);
|
||||
});
|
||||
|
||||
// Test 2: DST (Daylight Saving Time) transitions
|
||||
const dstTransitions = await performanceTracker.measureAsync(
|
||||
'dst-transition-handling',
|
||||
async () => {
|
||||
const dstTests = [
|
||||
{
|
||||
name: 'spring-forward-gap',
|
||||
dateTime: '2024-03-10T02:30:00',
|
||||
timezone: 'America/New_York',
|
||||
description: 'Time that does not exist due to DST'
|
||||
},
|
||||
{
|
||||
name: 'fall-back-ambiguous',
|
||||
dateTime: '2024-11-03T01:30:00',
|
||||
timezone: 'America/New_York',
|
||||
description: 'Time that occurs twice due to DST'
|
||||
},
|
||||
{
|
||||
name: 'dst-boundary-exact',
|
||||
dateTime: '2024-03-31T02:00:00',
|
||||
timezone: 'Europe/London',
|
||||
description: 'Exact moment of DST transition'
|
||||
},
|
||||
{
|
||||
name: 'southern-hemisphere-dst',
|
||||
dateTime: '2024-10-06T02:00:00',
|
||||
timezone: 'Australia/Sydney',
|
||||
description: 'Southern hemisphere DST transition'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of dstTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>DST-${test.name}</ID>
|
||||
<IssueDateTime>${test.dateTime}</IssueDateTime>
|
||||
<ProcessingTime timezone="${test.timezone}">${test.dateTime}</ProcessingTime>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const dstAnalysis = await einvoice.analyzeDSTIssues(parsed);
|
||||
|
||||
results.push({
|
||||
scenario: test.name,
|
||||
handled: true,
|
||||
hasAmbiguity: dstAnalysis?.isAmbiguous || false,
|
||||
isNonExistent: dstAnalysis?.isNonExistent || false,
|
||||
suggestion: dstAnalysis?.suggestion,
|
||||
adjustedTime: dstAnalysis?.adjusted
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
scenario: test.name,
|
||||
handled: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
dstTransitions.forEach(result => {
|
||||
t.ok(result.handled, `DST transition ${result.scenario} should be handled`);
|
||||
if (result.hasAmbiguity || result.isNonExistent) {
|
||||
t.ok(result.suggestion, 'DST issue should have suggestion');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Historic timezone changes
|
||||
const historicTimezones = await performanceTracker.measureAsync(
|
||||
'historic-timezone-changes',
|
||||
async () => {
|
||||
const historicTests = [
|
||||
{
|
||||
name: 'pre-timezone-standardization',
|
||||
dateTime: '1850-01-01T12:00:00',
|
||||
location: 'Europe/London',
|
||||
description: 'Before standard time zones'
|
||||
},
|
||||
{
|
||||
name: 'soviet-time-changes',
|
||||
dateTime: '1991-03-31T02:00:00',
|
||||
location: 'Europe/Moscow',
|
||||
description: 'USSR timezone reorganization'
|
||||
},
|
||||
{
|
||||
name: 'samoa-dateline-change',
|
||||
dateTime: '2011-12-30T00:00:00',
|
||||
location: 'Pacific/Apia',
|
||||
description: 'Samoa skipped December 30, 2011'
|
||||
},
|
||||
{
|
||||
name: 'crimea-timezone-change',
|
||||
dateTime: '2014-03-30T02:00:00',
|
||||
location: 'Europe/Simferopol',
|
||||
description: 'Crimea timezone change'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of historicTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>HISTORIC-${test.name}</ID>
|
||||
<HistoricDate>${test.dateTime}</HistoricDate>
|
||||
<Location>${test.location}</Location>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const historicAnalysis = await einvoice.handleHistoricDate(parsed, {
|
||||
validateHistoric: true
|
||||
});
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
processed: true,
|
||||
isHistoric: historicAnalysis?.isHistoric || false,
|
||||
hasTimezoneChange: historicAnalysis?.timezoneChanged || false,
|
||||
warnings: historicAnalysis?.warnings || []
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
processed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
historicTimezones.forEach(result => {
|
||||
t.ok(result.processed, `Historic timezone ${result.test} should be processed`);
|
||||
});
|
||||
|
||||
// Test 4: Fractional timezone offsets
|
||||
const fractionalTimezones = await performanceTracker.measureAsync(
|
||||
'fractional-timezone-offsets',
|
||||
async () => {
|
||||
const fractionalTests = [
|
||||
{
|
||||
name: 'newfoundland-half-hour',
|
||||
offset: '-03:30',
|
||||
timezone: 'America/St_Johns',
|
||||
dateTime: '2024-01-15T12:00:00-03:30'
|
||||
},
|
||||
{
|
||||
name: 'india-half-hour',
|
||||
offset: '+05:30',
|
||||
timezone: 'Asia/Kolkata',
|
||||
dateTime: '2024-01-15T12:00:00+05:30'
|
||||
},
|
||||
{
|
||||
name: 'nepal-quarter-hour',
|
||||
offset: '+05:45',
|
||||
timezone: 'Asia/Kathmandu',
|
||||
dateTime: '2024-01-15T12:00:00+05:45'
|
||||
},
|
||||
{
|
||||
name: 'chatham-islands',
|
||||
offset: '+12:45',
|
||||
timezone: 'Pacific/Chatham',
|
||||
dateTime: '2024-01-15T12:00:00+12:45'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of fractionalTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>FRAC-${test.name}</ID>
|
||||
<IssueDateTime>${test.dateTime}</IssueDateTime>
|
||||
<PaymentDueTime>${test.dateTime}</PaymentDueTime>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const normalized = await einvoice.normalizeToUTC(parsed);
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
offset: test.offset,
|
||||
parsed: true,
|
||||
correctlyHandled: normalized?.timezoneHandled || false,
|
||||
preservedPrecision: normalized?.precisionMaintained || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
offset: test.offset,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
fractionalTimezones.forEach(result => {
|
||||
t.ok(result.parsed, `Fractional timezone ${result.test} should be parsed`);
|
||||
if (result.parsed) {
|
||||
t.ok(result.correctlyHandled, 'Fractional offset should be handled correctly');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: Missing or ambiguous timezone info
|
||||
const ambiguousTimezones = await performanceTracker.measureAsync(
|
||||
'ambiguous-timezone-info',
|
||||
async () => {
|
||||
const ambiguousTests = [
|
||||
{
|
||||
name: 'no-timezone-info',
|
||||
xml: `<Invoice>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<IssueTime>14:30:00</IssueTime>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'conflicting-timezones',
|
||||
xml: `<Invoice>
|
||||
<IssueDateTime>2024-01-15T14:30:00+02:00</IssueDateTime>
|
||||
<Timezone>America/New_York</Timezone>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'local-time-only',
|
||||
xml: `<Invoice>
|
||||
<Timestamp>2024-01-15T14:30:00</Timestamp>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'invalid-offset',
|
||||
xml: `<Invoice>
|
||||
<DateTime>2024-01-15T14:30:00+25:00</DateTime>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of ambiguousTests) {
|
||||
const fullXml = `<?xml version="1.0" encoding="UTF-8"?>${test.xml}`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(fullXml);
|
||||
const timezoneAnalysis = await einvoice.resolveTimezones(parsed, {
|
||||
defaultTimezone: 'UTC',
|
||||
strict: false
|
||||
});
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
resolved: true,
|
||||
hasAmbiguity: timezoneAnalysis?.ambiguous || false,
|
||||
resolution: timezoneAnalysis?.resolution,
|
||||
confidence: timezoneAnalysis?.confidence || 0
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
resolved: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
ambiguousTimezones.forEach(result => {
|
||||
t.ok(result.resolved || result.error,
|
||||
`Ambiguous timezone ${result.test} should be handled`);
|
||||
if (result.resolved && result.hasAmbiguity) {
|
||||
t.ok(result.confidence < 100, 'Ambiguous timezone should have lower confidence');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: Leap seconds handling
|
||||
const leapSeconds = await performanceTracker.measureAsync(
|
||||
'leap-seconds-handling',
|
||||
async () => {
|
||||
const leapSecondTests = [
|
||||
{
|
||||
name: 'leap-second-23-59-60',
|
||||
dateTime: '2016-12-31T23:59:60Z',
|
||||
description: 'Actual leap second'
|
||||
},
|
||||
{
|
||||
name: 'near-leap-second',
|
||||
dateTime: '2016-12-31T23:59:59.999Z',
|
||||
description: 'Just before leap second'
|
||||
},
|
||||
{
|
||||
name: 'after-leap-second',
|
||||
dateTime: '2017-01-01T00:00:00.001Z',
|
||||
description: 'Just after leap second'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of leapSecondTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>LEAP-${test.name}</ID>
|
||||
<PreciseTimestamp>${test.dateTime}</PreciseTimestamp>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const timeHandling = await einvoice.handlePreciseTime(parsed);
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
handled: true,
|
||||
isLeapSecond: timeHandling?.isLeapSecond || false,
|
||||
adjusted: timeHandling?.adjusted || false,
|
||||
precision: timeHandling?.precision
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
handled: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
leapSeconds.forEach(result => {
|
||||
t.ok(result.handled || result.error,
|
||||
`Leap second ${result.test} should be processed`);
|
||||
});
|
||||
|
||||
// Test 7: Format-specific timezone handling
|
||||
const formatSpecificTimezones = await performanceTracker.measureAsync(
|
||||
'format-specific-timezone-handling',
|
||||
async () => {
|
||||
const formats = [
|
||||
{
|
||||
format: 'ubl',
|
||||
xml: `<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<IssueTime>14:30:00+02:00</IssueTime>
|
||||
</Invoice>`
|
||||
},
|
||||
{
|
||||
format: 'cii',
|
||||
xml: `<rsm:CrossIndustryInvoice>
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20240115143000</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
</rsm:ExchangedDocument>
|
||||
</rsm:CrossIndustryInvoice>`
|
||||
},
|
||||
{
|
||||
format: 'facturx',
|
||||
xml: `<Invoice>
|
||||
<IssueDateTime>2024-01-15T14:30:00</IssueDateTime>
|
||||
<TimeZoneOffset>+0200</TimeZoneOffset>
|
||||
</Invoice>`
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of formats) {
|
||||
try {
|
||||
const parsed = await einvoice.parseDocument(test.xml);
|
||||
const standardized = await einvoice.standardizeDateTime(parsed, {
|
||||
sourceFormat: test.format
|
||||
});
|
||||
|
||||
results.push({
|
||||
format: test.format,
|
||||
parsed: true,
|
||||
hasDateTime: !!standardized?.dateTime,
|
||||
hasTimezone: !!standardized?.timezone,
|
||||
normalized: standardized?.normalized || false
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
format: test.format,
|
||||
parsed: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
formatSpecificTimezones.forEach(result => {
|
||||
t.ok(result.parsed, `Format ${result.format} timezone should be handled`);
|
||||
});
|
||||
|
||||
// Test 8: Business day calculations across timezones
|
||||
const businessDayCalculations = await performanceTracker.measureAsync(
|
||||
'business-day-calculations',
|
||||
async () => {
|
||||
const businessTests = [
|
||||
{
|
||||
name: 'payment-terms-30-days',
|
||||
issueDate: '2024-01-15T23:00:00+12:00',
|
||||
terms: 30,
|
||||
expectedDue: '2024-02-14'
|
||||
},
|
||||
{
|
||||
name: 'cross-month-boundary',
|
||||
issueDate: '2024-01-31T22:00:00-05:00',
|
||||
terms: 1,
|
||||
expectedDue: '2024-02-01'
|
||||
},
|
||||
{
|
||||
name: 'weekend-adjustment',
|
||||
issueDate: '2024-01-12T18:00:00Z', // Friday
|
||||
terms: 3,
|
||||
expectedDue: '2024-01-17' // Skip weekend
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of businessTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>BUSINESS-${test.name}</ID>
|
||||
<IssueDateTime>${test.issueDate}</IssueDateTime>
|
||||
<PaymentTerms>
|
||||
<NetDays>${test.terms}</NetDays>
|
||||
</PaymentTerms>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const calculated = await einvoice.calculateDueDate(parsed, {
|
||||
skipWeekends: true,
|
||||
skipHolidays: true,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
calculated: true,
|
||||
dueDate: calculated?.dueDate,
|
||||
matchesExpected: calculated?.dueDate === test.expectedDue,
|
||||
businessDaysUsed: calculated?.businessDays
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
calculated: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
businessDayCalculations.forEach(result => {
|
||||
t.ok(result.calculated, `Business day calculation ${result.test} should work`);
|
||||
});
|
||||
|
||||
// Test 9: Timezone conversion errors
|
||||
const timezoneConversionErrors = await performanceTracker.measureAsync(
|
||||
'timezone-conversion-errors',
|
||||
async () => {
|
||||
const errorTests = [
|
||||
{
|
||||
name: 'invalid-timezone-name',
|
||||
timezone: 'Invalid/Timezone',
|
||||
dateTime: '2024-01-15T12:00:00'
|
||||
},
|
||||
{
|
||||
name: 'deprecated-timezone',
|
||||
timezone: 'US/Eastern', // Deprecated, use America/New_York
|
||||
dateTime: '2024-01-15T12:00:00'
|
||||
},
|
||||
{
|
||||
name: 'military-timezone',
|
||||
timezone: 'Z', // Zulu time
|
||||
dateTime: '2024-01-15T12:00:00'
|
||||
},
|
||||
{
|
||||
name: 'three-letter-timezone',
|
||||
timezone: 'EST', // Ambiguous
|
||||
dateTime: '2024-01-15T12:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of errorTests) {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice>
|
||||
<ID>ERROR-${test.name}</ID>
|
||||
<DateTime timezone="${test.timezone}">${test.dateTime}</DateTime>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const parsed = await einvoice.parseXML(xml);
|
||||
const converted = await einvoice.convertTimezone(parsed, {
|
||||
from: test.timezone,
|
||||
to: 'UTC',
|
||||
strict: true
|
||||
});
|
||||
|
||||
results.push({
|
||||
test: test.name,
|
||||
handled: true,
|
||||
converted: !!converted,
|
||||
fallbackUsed: converted?.fallback || false,
|
||||
warning: converted?.warning
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: test.name,
|
||||
handled: false,
|
||||
error: error.message,
|
||||
isTimezoneError: error.message.includes('timezone') ||
|
||||
error.message.includes('time zone')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
timezoneConversionErrors.forEach(result => {
|
||||
t.ok(result.handled || result.isTimezoneError,
|
||||
`Timezone error ${result.test} should be handled appropriately`);
|
||||
});
|
||||
|
||||
// Test 10: Cross-format timezone preservation
|
||||
const crossFormatTimezones = await performanceTracker.measureAsync(
|
||||
'cross-format-timezone-preservation',
|
||||
async () => {
|
||||
const testData = {
|
||||
dateTime: '2024-01-15T14:30:00+05:30',
|
||||
timezone: 'Asia/Kolkata'
|
||||
};
|
||||
|
||||
const sourceUBL = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>TZ-PRESERVE-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<IssueTime>${testData.dateTime}</IssueTime>
|
||||
</Invoice>`;
|
||||
|
||||
const conversions = ['cii', 'xrechnung', 'facturx'];
|
||||
const results = [];
|
||||
|
||||
for (const targetFormat of conversions) {
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(sourceUBL, targetFormat);
|
||||
const reparsed = await einvoice.parseDocument(converted);
|
||||
const extractedDateTime = await einvoice.extractDateTime(reparsed);
|
||||
|
||||
results.push({
|
||||
targetFormat,
|
||||
converted: true,
|
||||
timezonePreserved: extractedDateTime?.timezone === testData.timezone,
|
||||
offsetPreserved: extractedDateTime?.offset === '+05:30',
|
||||
dateTimeIntact: extractedDateTime?.iso === testData.dateTime
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
targetFormat,
|
||||
converted: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
crossFormatTimezones.forEach(result => {
|
||||
t.ok(result.converted, `Conversion to ${result.targetFormat} should succeed`);
|
||||
if (result.converted) {
|
||||
t.ok(result.timezonePreserved || result.offsetPreserved,
|
||||
'Timezone information should be preserved');
|
||||
}
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
Reference in New Issue
Block a user