import { tap, expect } from '@git.zone/tstest/tapbundle'; import { EInvoice } from '../../../ts/index.js'; import { ValidationLevel } from '../../../ts/interfaces/common.js'; import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.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 (t) => { // 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.getFiles(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: 'Structure', expectedError: 'structure' }, { name: 'missing-required.xml', content: '', expectedError: 'validation' }, { name: 'malformed-encoding.xml', content: 'Ä Invalid UTF-8 bytes', 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(), errorMessages: new Map(), recoveryAttempts: 0, partialRecoveries: 0 }; // Test corpus failed files t.test('Corpus failed files handling', async (st) => { 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.EXTENDED); 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); st.pass(`✓ ${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++; st.pass(` - Partial recovery successful`); } } } else { // File was expected to fail but didn't st.fail(`✗ ${path.basename(file.path)}: Expected to fail but succeeded`); } } catch (unexpectedError: any) { results.unhandled++; st.fail(`✗ ${path.basename(file.path)}: Unhandled error - ${unexpectedError.message}`); } } }); // Test synthetic invalid files t.test('Synthetic invalid files handling', async (st) => { 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) { st.pass(`✓ ${invalid.name}: Correctly failed with ${errorType} error`); if (errorType !== invalid.expectedError && invalid.expectedError !== 'any') { st.comment(` Note: Expected ${invalid.expectedError} but got ${errorType}`); } } else { st.fail(`✗ ${invalid.name}: Should have failed but succeeded`); } } catch (unexpectedError: any) { results.unhandled++; st.fail(`✗ ${invalid.name}: Unhandled error - ${unexpectedError.message}`); } } }); // Test error message quality t.test('Error message quality', async (st) => { const testCases = [ { xml: '', check: 'descriptive' }, { xml: '', check: 'namespace' }, { xml: '', 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) { st.pass(`✓ Good error message quality for ${testCase.check}`); st.comment(` Message: ${error.message.substring(0, 80)}...`); } else { st.fail(`✗ Poor error message quality for ${testCase.check}`); } } } catch (error: any) { // Parse errors should also have good messages if (error.message && error.message.length > 20) { st.pass(`✓ Parse error has descriptive message`); } } } }); // Test error recovery mechanisms t.test('Error recovery mechanisms', async (st) => { const recoverableErrors = [ { name: 'missing-closing-tag', xml: '123', recovery: 'auto-close' }, { name: 'encoding-issue', xml: 'Café', recovery: 'encoding-fix' }, { name: 'namespace-mismatch', xml: '123', recovery: 'namespace-fix' } ]; for (const testCase of recoverableErrors) { const invoice = new EInvoice(); const recovered = await attemptRecovery(testCase.xml, invoice); if (recovered) { st.pass(`✓ ${testCase.name}: Recovery successful using ${testCase.recovery}`); } else { st.comment(` ${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.95); // 95% of errors should be handled gracefully // No unhandled errors in production expect(results.unhandled).toBeLessThan(results.totalFiles * 0.05); // Less than 5% 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 { // 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('\s]+)/g); if (tags) { let fixedXml = xml; tags.reverse().forEach(tag => { const tagName = tag.substring(1); if (!fixedXml.includes(``)) { fixedXml += ``; } }); 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();