377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import { EInvoice } from '../../../ts/index.js';
|
|
import { ValidationLevel } from '../../../ts/interfaces/common.js';
|
|
import { CorpusLoader } from '../../helpers/corpus.loader.js';
|
|
import { PerformanceTracker } from '../../helpers/performance.tracker.js';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs/promises';
|
|
|
|
/**
|
|
* Test ID: CORP-08
|
|
* Test Description: Failed Invoice Handling
|
|
* Priority: High
|
|
*
|
|
* This test validates proper error handling and recovery when processing
|
|
* invalid or malformed invoices from the corpus.
|
|
*/
|
|
|
|
tap.test('CORP-08: Failed Invoice Handling - should handle invalid invoices gracefully', async () => {
|
|
// Load failed/invalid test files from various categories
|
|
const failCategories = [
|
|
'ZUGFERD_V1_FAIL',
|
|
'ZUGFERD_V2_FAIL',
|
|
'EN16931_INVALID'
|
|
];
|
|
|
|
const failedFiles: Array<{ path: string; size: number; category: string }> = [];
|
|
|
|
// Collect all failed invoice files
|
|
for (const category of failCategories) {
|
|
try {
|
|
const files = await CorpusLoader.loadCategory(category);
|
|
failedFiles.push(...files.map(f => ({ ...f, category })));
|
|
} catch (e) {
|
|
// Category might not exist
|
|
console.log(`Category ${category} not found, skipping...`);
|
|
}
|
|
}
|
|
|
|
// Also test some synthetic invalid files
|
|
const syntheticInvalids = [
|
|
{
|
|
name: 'empty.xml',
|
|
content: '',
|
|
expectedError: 'empty'
|
|
},
|
|
{
|
|
name: 'not-xml.xml',
|
|
content: 'This is not XML content',
|
|
expectedError: 'parse'
|
|
},
|
|
{
|
|
name: 'invalid-structure.xml',
|
|
content: '<?xml version="1.0"?><Invoice><Invalid>Structure</Wrong></Invoice>',
|
|
expectedError: 'structure'
|
|
},
|
|
{
|
|
name: 'missing-required.xml',
|
|
content: '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"></Invoice>',
|
|
expectedError: 'validation'
|
|
},
|
|
{
|
|
name: 'malformed-encoding.xml',
|
|
content: '<?xml version="1.0" encoding="UTF-8"?><Invoice>Ä Invalid UTF-8 bytes</Invoice>',
|
|
expectedError: 'encoding'
|
|
}
|
|
];
|
|
|
|
console.log(`Testing ${failedFiles.length} failed corpus files and ${syntheticInvalids.length} synthetic invalid files`);
|
|
|
|
const results = {
|
|
totalFiles: failedFiles.length + syntheticInvalids.length,
|
|
handled: 0,
|
|
unhandled: 0,
|
|
errorTypes: new Map<string, number>(),
|
|
errorMessages: new Map<string, number>(),
|
|
recoveryAttempts: 0,
|
|
partialRecoveries: 0
|
|
};
|
|
|
|
// Test corpus failed files
|
|
console.log('\n--- Testing corpus failed files ---');
|
|
if (failedFiles.length > 0) {
|
|
for (const file of failedFiles) {
|
|
try {
|
|
const xmlBuffer = await CorpusLoader.loadFile(file.path);
|
|
const xmlString = xmlBuffer.toString('utf-8');
|
|
|
|
const invoice = new EInvoice();
|
|
let error: any = null;
|
|
let stage = 'unknown';
|
|
|
|
try {
|
|
// Attempt to parse
|
|
stage = 'parse';
|
|
await invoice.fromXmlString(xmlString);
|
|
|
|
// Attempt to validate
|
|
stage = 'validate';
|
|
const validationResult = await invoice.validate(ValidationLevel.BUSINESS);
|
|
|
|
if (!validationResult.valid) {
|
|
error = new Error(validationResult.errors?.[0]?.message || 'Validation failed');
|
|
error.type = 'validation';
|
|
error.details = validationResult.errors;
|
|
}
|
|
} catch (e: any) {
|
|
error = e;
|
|
error.type = stage;
|
|
}
|
|
|
|
if (error) {
|
|
results.handled++;
|
|
|
|
// Categorize error
|
|
const errorType = error.type || 'unknown';
|
|
results.errorTypes.set(errorType, (results.errorTypes.get(errorType) || 0) + 1);
|
|
|
|
// Track common error messages
|
|
const errorMsg = error.message.substring(0, 50);
|
|
results.errorMessages.set(errorMsg, (results.errorMessages.get(errorMsg) || 0) + 1);
|
|
|
|
console.log(`✓ ${path.basename(file.path)}: Error handled properly (${errorType})`);
|
|
|
|
// Test error recovery attempt
|
|
if (errorType === 'parse') {
|
|
results.recoveryAttempts++;
|
|
|
|
// Try recovery strategies
|
|
const recovered = await attemptRecovery(xmlString, invoice);
|
|
if (recovered) {
|
|
results.partialRecoveries++;
|
|
console.log(` - Partial recovery successful`);
|
|
}
|
|
}
|
|
} else {
|
|
// File was expected to fail but didn't
|
|
console.log(`✗ ${path.basename(file.path)}: Expected to fail but succeeded`);
|
|
}
|
|
|
|
} catch (unexpectedError: any) {
|
|
results.unhandled++;
|
|
console.log(`✗ ${path.basename(file.path)}: Unhandled error - ${unexpectedError.message}`);
|
|
}
|
|
}
|
|
} else {
|
|
console.log('⚠ No failed files found in corpus - skipping test');
|
|
}
|
|
|
|
// Test synthetic invalid files
|
|
console.log('\n--- Testing synthetic invalid files ---');
|
|
for (const invalid of syntheticInvalids) {
|
|
try {
|
|
const invoice = new EInvoice();
|
|
let errorOccurred = false;
|
|
let errorType = '';
|
|
|
|
try {
|
|
await invoice.fromXmlString(invalid.content);
|
|
|
|
// If parsing succeeded, try validation
|
|
const validationResult = await invoice.validate();
|
|
if (!validationResult.valid) {
|
|
errorOccurred = true;
|
|
errorType = 'validation';
|
|
}
|
|
} catch (error: any) {
|
|
errorOccurred = true;
|
|
errorType = determineErrorType(error);
|
|
results.handled++;
|
|
|
|
// Track error type
|
|
results.errorTypes.set(errorType, (results.errorTypes.get(errorType) || 0) + 1);
|
|
}
|
|
|
|
if (errorOccurred) {
|
|
console.log(`✓ ${invalid.name}: Correctly failed with ${errorType} error`);
|
|
|
|
if (errorType !== invalid.expectedError && invalid.expectedError !== 'any') {
|
|
console.log(` Note: Expected ${invalid.expectedError} but got ${errorType}`);
|
|
}
|
|
} else {
|
|
console.log(`✗ ${invalid.name}: Should have failed but succeeded`);
|
|
}
|
|
|
|
} catch (unexpectedError: any) {
|
|
results.unhandled++;
|
|
console.log(`✗ ${invalid.name}: Unhandled error - ${unexpectedError.message}`);
|
|
}
|
|
}
|
|
|
|
// Test error message quality
|
|
console.log('\n--- Testing error message quality ---');
|
|
const testCases = [
|
|
{
|
|
xml: '<Invoice/>',
|
|
check: 'descriptive'
|
|
},
|
|
{
|
|
xml: '<?xml version="1.0"?><Invoice xmlns="bad-namespace"/>',
|
|
check: 'namespace'
|
|
},
|
|
{
|
|
xml: '<?xml version="1.0"?><CrossIndustryInvoice><ExchangedDocument><ID></ID></ExchangedDocument></CrossIndustryInvoice>',
|
|
check: 'required-field'
|
|
}
|
|
];
|
|
|
|
for (const testCase of testCases) {
|
|
try {
|
|
const invoice = new EInvoice();
|
|
await invoice.fromXmlString(testCase.xml);
|
|
const result = await invoice.validate();
|
|
|
|
if (!result.valid && result.errors?.length) {
|
|
const error = result.errors[0];
|
|
|
|
// Check error message quality
|
|
const hasErrorCode = !!error.code;
|
|
const hasDescription = error.message.length > 20;
|
|
const hasContext = !!error.path || !!error.field;
|
|
|
|
if (hasErrorCode && hasDescription) {
|
|
console.log(`✓ Good error message quality for ${testCase.check}`);
|
|
console.log(` Message: ${error.message.substring(0, 80)}...`);
|
|
} else {
|
|
console.log(`✗ Poor error message quality for ${testCase.check}`);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
// Parse errors should also have good messages
|
|
if (error.message && error.message.length > 20) {
|
|
console.log(`✓ Parse error has descriptive message`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test error recovery mechanisms
|
|
console.log('\n--- Testing error recovery mechanisms ---');
|
|
const recoverableErrors = [
|
|
{
|
|
name: 'missing-closing-tag',
|
|
xml: '<?xml version="1.0"?><Invoice><ID>123</ID>',
|
|
recovery: 'auto-close'
|
|
},
|
|
{
|
|
name: 'encoding-issue',
|
|
xml: '<?xml version="1.0" encoding="ISO-8859-1"?><Invoice><Name>Café</Name></Invoice>',
|
|
recovery: 'encoding-fix'
|
|
},
|
|
{
|
|
name: 'namespace-mismatch',
|
|
xml: '<Invoice xmlns="wrong-namespace"><ID>123</ID></Invoice>',
|
|
recovery: 'namespace-fix'
|
|
}
|
|
];
|
|
|
|
for (const testCase of recoverableErrors) {
|
|
const invoice = new EInvoice();
|
|
const recovered = await attemptRecovery(testCase.xml, invoice);
|
|
|
|
if (recovered) {
|
|
console.log(`✓ ${testCase.name}: Recovery successful using ${testCase.recovery}`);
|
|
} else {
|
|
console.log(` ${testCase.name}: Recovery not implemented`);
|
|
}
|
|
}
|
|
|
|
// Summary report
|
|
console.log('\n=== Failed Invoice Handling Summary ===');
|
|
console.log(`Total files tested: ${results.totalFiles}`);
|
|
console.log(`Properly handled: ${results.handled} (${(results.handled/results.totalFiles*100).toFixed(1)}%)`);
|
|
console.log(`Unhandled errors: ${results.unhandled}`);
|
|
|
|
console.log('\nError Types Distribution:');
|
|
results.errorTypes.forEach((count, type) => {
|
|
console.log(` ${type}: ${count} occurrences`);
|
|
});
|
|
|
|
console.log('\nCommon Error Messages:');
|
|
const sortedErrors = Array.from(results.errorMessages.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5);
|
|
sortedErrors.forEach(([msg, count]) => {
|
|
console.log(` "${msg}...": ${count} times`);
|
|
});
|
|
|
|
console.log('\nRecovery Statistics:');
|
|
console.log(` Recovery attempts: ${results.recoveryAttempts}`);
|
|
console.log(` Partial recoveries: ${results.partialRecoveries}`);
|
|
console.log(` Recovery rate: ${results.recoveryAttempts > 0 ?
|
|
(results.partialRecoveries/results.recoveryAttempts*100).toFixed(1) : 0}%`);
|
|
|
|
// Success criteria
|
|
const handlingRate = results.handled / results.totalFiles;
|
|
expect(handlingRate).toBeGreaterThan(0.75); // 75% of errors should be handled gracefully
|
|
|
|
// No unhandled errors in production
|
|
expect(results.unhandled).toBeLessThan(results.totalFiles * 0.25); // Less than 25% unhandled
|
|
});
|
|
|
|
// Helper function to determine error type
|
|
function determineErrorType(error: Error): string {
|
|
const message = error.message.toLowerCase();
|
|
|
|
if (message.includes('parse') || message.includes('syntax')) return 'parse';
|
|
if (message.includes('encoding') || message.includes('utf')) return 'encoding';
|
|
if (message.includes('valid')) return 'validation';
|
|
if (message.includes('require') || message.includes('missing')) return 'required-field';
|
|
if (message.includes('namespace')) return 'namespace';
|
|
if (message.includes('empty')) return 'empty';
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
// Helper function to attempt recovery
|
|
async function attemptRecovery(xml: string, invoice: EInvoice): Promise<boolean> {
|
|
// Try various recovery strategies
|
|
|
|
// 1. Try to fix encoding
|
|
if (xml.includes('encoding=') && !xml.includes('UTF-8')) {
|
|
try {
|
|
const utf8Xml = xml.replace(/encoding="[^"]*"/, 'encoding="UTF-8"');
|
|
await invoice.fromXmlString(utf8Xml);
|
|
return true;
|
|
} catch (e) {
|
|
// Continue to next strategy
|
|
}
|
|
}
|
|
|
|
// 2. Try to auto-close tags
|
|
if (!xml.includes('</') && xml.includes('<')) {
|
|
try {
|
|
// Simple auto-close attempt
|
|
const tags = xml.match(/<([^\/>\s]+)/g);
|
|
if (tags) {
|
|
let fixedXml = xml;
|
|
tags.reverse().forEach(tag => {
|
|
const tagName = tag.substring(1);
|
|
if (!fixedXml.includes(`</${tagName}>`)) {
|
|
fixedXml += `</${tagName}>`;
|
|
}
|
|
});
|
|
await invoice.fromXmlString(fixedXml);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
// Continue
|
|
}
|
|
}
|
|
|
|
// 3. Try namespace fixes
|
|
if (xml.includes('xmlns=')) {
|
|
try {
|
|
// Try with common namespaces
|
|
const namespaces = [
|
|
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
|
'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'
|
|
];
|
|
|
|
for (const ns of namespaces) {
|
|
const fixedXml = xml.replace(/xmlns="[^"]*"/, `xmlns="${ns}"`);
|
|
try {
|
|
await invoice.fromXmlString(fixedXml);
|
|
return true;
|
|
} catch (e) {
|
|
// Try next namespace
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Failed
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
tap.start(); |