einvoice/test/test.validation-suite.ts

389 lines
13 KiB
TypeScript

import { tap, expect } from '@push.rocks/tapbundle';
import { EInvoice, EInvoiceValidationError } from '../ts/index.js';
import { ValidationLevel, InvoiceFormat } from '../ts/interfaces/common.js';
import { TestFileHelpers, TestFileCategories, InvoiceAssertions, PerformanceUtils } from './test-utils.js';
import * as plugins from '../ts/plugins.js';
/**
* Comprehensive validation test suite using EN16931 test cases
*/
// Test Business Rule validations from EN16931
tap.test('Validation Suite - EN16931 Business Rules (BR-*)', async () => {
const testFiles = await TestFileHelpers.getTestFiles(TestFileCategories.EN16931_UBL_INVOICE, 'BR-*.xml');
console.log(`Testing ${testFiles.length} Business Rule validation files`);
const results = {
passed: 0,
failed: 0,
errors: [] as string[]
};
for (const file of testFiles) {
const fileName = plugins.path.basename(file);
const shouldFail = fileName.includes('BR-'); // These files test specific BR violations
try {
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
const xmlString = xmlBuffer.toString('utf-8');
const einvoice = await EInvoice.fromXml(xmlString);
const { result: validation, duration } = await PerformanceUtils.measure(
'br-validation',
async () => einvoice.validate(ValidationLevel.BUSINESS)
);
// Most BR-*.xml files are designed to fail specific business rules
if (shouldFail && !validation.valid) {
results.passed++;
console.log(`${fileName}: Correctly failed validation (${duration.toFixed(2)}ms)`);
// Check that the correct BR code is in the errors
const brCode = fileName.match(/BR-\d+/)?.[0];
if (brCode) {
const hasCorrectError = validation.errors.some(e => e.code.includes(brCode));
if (!hasCorrectError) {
console.log(` ⚠ Expected error code ${brCode} not found in: ${validation.errors.map(e => e.code).join(', ')}`);
}
}
} else if (!shouldFail && validation.valid) {
results.passed++;
console.log(`${fileName}: Correctly passed validation (${duration.toFixed(2)}ms)`);
} else {
results.failed++;
results.errors.push(`${fileName}: Unexpected result - valid: ${validation.valid}`);
console.log(`${fileName}: Unexpected validation result`);
if (validation.errors.length > 0) {
console.log(` Errors: ${validation.errors.map(e => `${e.code}: ${e.message}`).join('; ')}`);
}
}
} catch (error) {
results.failed++;
results.errors.push(`${fileName}: ${error.message}`);
console.log(`${fileName}: Error - ${error.message}`);
}
}
console.log(`\nBusiness Rules Summary: ${results.passed} passed, ${results.failed} failed`);
if (results.errors.length > 0) {
console.log('Failures:', results.errors);
}
// Allow some failures as not all validators may be implemented
expect(results.passed).toBeGreaterThan(0);
});
// Test Codelist validations
tap.test('Validation Suite - EN16931 Codelist validations (BR-CL-*)', async () => {
const testFiles = await TestFileHelpers.getTestFiles(TestFileCategories.EN16931_UBL_INVOICE, 'BR-CL-*.xml');
console.log(`Testing ${testFiles.length} Codelist validation files`);
let validatedCount = 0;
for (const file of testFiles.slice(0, 10)) { // Test first 10 for speed
const fileName = plugins.path.basename(file);
try {
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
const xmlString = xmlBuffer.toString('utf-8');
const einvoice = await EInvoice.fromXml(xmlString);
const validation = await einvoice.validate(ValidationLevel.SEMANTIC);
validatedCount++;
// These files test invalid code values
if (!validation.valid) {
const clCode = fileName.match(/BR-CL-\d+/)?.[0];
console.log(`${fileName}: Detected invalid code (${clCode})`);
} else {
console.log(`${fileName}: Validation passed (may need stricter codelist checking)`);
}
} catch (error) {
console.log(`${fileName}: Error - ${error.message}`);
}
}
expect(validatedCount).toBeGreaterThan(0);
console.log(`Validated ${validatedCount} codelist test files`);
});
// Test syntax validation
tap.test('Validation Suite - Syntax validation levels', async () => {
const xmlWithError = `<?xml version="1.0"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<ID>123</ID>
<IssueDate>not-a-date</IssueDate>
<InvalidElement>This element doesn't belong here</InvalidElement>
</Invoice>`;
const einvoice = new EInvoice();
// Test that we can catch parsing errors
try {
await einvoice.loadXml(xmlWithError);
// Syntax validation should catch schema violations
const syntaxValidation = await einvoice.validate(ValidationLevel.SYNTAX);
console.log('Syntax validation:', syntaxValidation.valid ? 'PASSED' : 'FAILED');
if (!syntaxValidation.valid) {
console.log('Syntax errors found:', syntaxValidation.errors.length);
syntaxValidation.errors.forEach(err => {
console.log(` - ${err.code}: ${err.message}`);
});
}
} catch (error) {
if (error instanceof EInvoiceValidationError) {
console.log('✓ Validation error caught correctly');
console.log(error.getValidationReport());
}
}
});
// Test validation error reporting
tap.test('Validation Suite - Error reporting and recovery', async () => {
const testInvoice = new EInvoice();
// Try to validate without loading XML
try {
await testInvoice.validate();
} catch (error) {
expect(error).toBeInstanceOf(EInvoiceValidationError);
if (error instanceof EInvoiceValidationError) {
expect(error.validationErrors).toHaveLength(1);
expect(error.validationErrors[0].code).toEqual('VAL-001');
console.log('✓ Empty invoice validation error handled correctly');
}
}
// Test with minimal valid invoice
testInvoice.id = 'TEST-001';
testInvoice.invoiceId = 'INV-001';
testInvoice.from.name = 'Test Seller';
testInvoice.to.name = 'Test Buyer';
testInvoice.items = [{
position: 1,
name: 'Test Item',
unitQuantity: 1,
unitNetPrice: 100,
vatPercentage: 19
}];
// This should fail because we don't have XML loaded
try {
await testInvoice.validate();
} catch (error) {
expect(error).toBeInstanceOf(EInvoiceValidationError);
console.log('✓ Validation requires loaded XML');
}
});
// Test format-specific validation
tap.test('Validation Suite - Format-specific validation rules', async () => {
// Test XRechnung specific validation
const xrechnungFiles = await TestFileHelpers.getTestFiles(
TestFileCategories.CII_XMLRECHNUNG,
'XRECHNUNG_*.xml'
);
if (xrechnungFiles.length > 0) {
console.log(`Testing ${xrechnungFiles.length} XRechnung files`);
for (const file of xrechnungFiles.slice(0, 3)) {
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
const einvoice = await EInvoice.fromXml(xmlBuffer.toString('utf-8'));
const validation = await einvoice.validate(ValidationLevel.BUSINESS);
console.log(`${plugins.path.basename(file)}: ${validation.valid ? 'VALID' : 'INVALID'}`);
if (!validation.valid && validation.errors.length > 0) {
console.log(` First error: ${validation.errors[0].code} - ${validation.errors[0].message}`);
}
}
}
// Test ZUGFeRD specific validation
console.log('\nTesting ZUGFeRD profile validation:');
const zugferdPdfs = await TestFileHelpers.getTestFiles(
TestFileCategories.ZUGFERD_V2_CORRECT,
'*.pdf'
);
for (const file of zugferdPdfs.slice(0, 2)) {
try {
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
const einvoice = await EInvoice.fromPdf(pdfBuffer);
// Check which ZUGFeRD profile is used
const format = einvoice.getFormat();
console.log(`${plugins.path.basename(file)}: Format ${format}`);
// Validate according to profile
const validation = await einvoice.validate(ValidationLevel.SEMANTIC);
console.log(` Validation: ${validation.valid ? 'VALID' : 'INVALID'}`);
} catch (error) {
console.log(`${plugins.path.basename(file)}: Skipped - ${error.message}`);
}
}
});
// Test validation performance
tap.test('Validation Suite - Performance benchmarks', async () => {
const files = await TestFileHelpers.getTestFiles(
TestFileCategories.UBL_XMLRECHNUNG,
'*.xml'
);
if (files.length > 0) {
const xmlBuffer = await TestFileHelpers.loadTestFile(files[0]);
const xmlString = xmlBuffer.toString('utf-8');
const einvoice = await EInvoice.fromXml(xmlString);
// Benchmark different validation levels
console.log('\nValidation Performance:');
// Syntax validation
const syntaxTimes: number[] = [];
for (let i = 0; i < 10; i++) {
const { duration } = await PerformanceUtils.measure(
'syntax-validation',
async () => einvoice.validate(ValidationLevel.SYNTAX)
);
syntaxTimes.push(duration);
}
const avgSyntax = syntaxTimes.reduce((a, b) => a + b) / syntaxTimes.length;
console.log(`Syntax validation: avg ${avgSyntax.toFixed(2)}ms`);
// Semantic validation
const semanticTimes: number[] = [];
for (let i = 0; i < 10; i++) {
const { duration } = await PerformanceUtils.measure(
'semantic-validation',
async () => einvoice.validate(ValidationLevel.SEMANTIC)
);
semanticTimes.push(duration);
}
const avgSemantic = semanticTimes.reduce((a, b) => a + b) / semanticTimes.length;
console.log(`Semantic validation: avg ${avgSemantic.toFixed(2)}ms`);
// Business validation
const businessTimes: number[] = [];
for (let i = 0; i < 10; i++) {
const { duration } = await PerformanceUtils.measure(
'business-validation',
async () => einvoice.validate(ValidationLevel.BUSINESS)
);
businessTimes.push(duration);
}
const avgBusiness = businessTimes.reduce((a, b) => a + b) / businessTimes.length;
console.log(`Business validation: avg ${avgBusiness.toFixed(2)}ms`);
// Validation should get progressively slower with higher levels
expect(avgSyntax).toBeLessThan(avgSemantic);
expect(avgSemantic).toBeLessThan(avgBusiness);
}
});
// Test calculation validations
tap.test('Validation Suite - Calculation and sum validations', async () => {
const einvoice = new EInvoice();
einvoice.id = 'CALC-TEST-001';
einvoice.invoiceId = 'CALC-001';
einvoice.from.name = 'Calculator Corp';
einvoice.to.name = 'Number Inc';
// Add items with specific calculations
einvoice.items = [
{
position: 1,
name: 'Product A',
unitQuantity: 5,
unitNetPrice: 100, // Total: 500
vatPercentage: 19 // VAT: 95
},
{
position: 2,
name: 'Product B',
unitQuantity: 3,
unitNetPrice: 50, // Total: 150
vatPercentage: 19 // VAT: 28.50
}
];
// Expected totals:
// Net: 650
// VAT: 123.50
// Gross: 773.50
// Generate XML and validate
try {
const xml = await einvoice.exportXml('facturx');
await einvoice.loadXml(xml);
const validation = await einvoice.validate(ValidationLevel.BUSINESS);
if (!validation.valid) {
const calcErrors = validation.errors.filter(e =>
e.code.includes('BR-CO') || e.message.toLowerCase().includes('calc')
);
if (calcErrors.length > 0) {
console.log('Calculation validation errors found:');
calcErrors.forEach(err => {
console.log(` - ${err.code}: ${err.message}`);
});
}
} else {
console.log('✓ Invoice calculations validated successfully');
}
} catch (error) {
console.log(`Calculation validation test skipped: ${error.message}`);
}
});
// Test validation caching
tap.test('Validation Suite - Validation result caching', async () => {
const xmlBuffer = await TestFileHelpers.loadTestFile(
`${TestFileCategories.UBL_XMLRECHNUNG}/EN16931_Einfach.ubl.xml`
);
const einvoice = await EInvoice.fromXml(xmlBuffer.toString('utf-8'));
// First validation (cold)
const { duration: coldDuration } = await PerformanceUtils.measure(
'validation-cold',
async () => einvoice.validate(ValidationLevel.BUSINESS)
);
// Second validation (potentially cached)
const { duration: warmDuration } = await PerformanceUtils.measure(
'validation-warm',
async () => einvoice.validate(ValidationLevel.BUSINESS)
);
console.log(`Cold validation: ${coldDuration.toFixed(2)}ms`);
console.log(`Warm validation: ${warmDuration.toFixed(2)}ms`);
// Check if errors are consistent
const errors1 = einvoice.getValidationErrors();
const errors2 = einvoice.getValidationErrors();
expect(errors1).toEqual(errors2);
});
// Generate validation summary
tap.test('Validation Suite - Summary Report', async () => {
const stats = PerformanceUtils.getStats('br-validation');
if (stats) {
console.log('\nBusiness Rule Validation Performance:');
console.log(` Total validations: ${stats.count}`);
console.log(` Average time: ${stats.avg.toFixed(2)}ms`);
console.log(` Min/Max: ${stats.min.toFixed(2)}ms / ${stats.max.toFixed(2)}ms`);
}
// Test that validation is generally performant
if (stats && stats.count > 10) {
expect(stats.avg).toBeLessThan(100); // Should validate in under 100ms on average
}
});
tap.start();