feat(tests): fully implemented test suite
This commit is contained in:
@ -0,0 +1,376 @@
|
||||
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: '<?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
|
||||
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: '<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) {
|
||||
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: '<?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) {
|
||||
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<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();
|
Reference in New Issue
Block a user