2025-05-25 19:45:37 +00:00
|
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
|
|
import { promises as fs } from 'fs';
|
|
|
|
import * as path from 'path';
|
|
|
|
import { CorpusLoader } from '../../helpers/corpus.loader.js';
|
|
|
|
import { PerformanceTracker } from '../../helpers/performance.tracker.js';
|
|
|
|
|
|
|
|
tap.test('CONV-01: Format Conversion - should convert between invoice formats', async () => {
|
|
|
|
// Test conversion between CII and UBL using paired files
|
|
|
|
const ciiFiles = await CorpusLoader.getFiles('CII_XMLRECHNUNG');
|
|
|
|
const ublFiles = await CorpusLoader.getFiles('UBL_XMLRECHNUNG');
|
|
|
|
|
|
|
|
// Find paired files (same base name)
|
|
|
|
const pairs: Array<{cii: string, ubl: string, name: string}> = [];
|
|
|
|
|
|
|
|
for (const ciiFile of ciiFiles) {
|
|
|
|
const baseName = path.basename(ciiFile).replace('.cii.xml', '');
|
|
|
|
const matchingUbl = ublFiles.find(ubl =>
|
|
|
|
path.basename(ubl).startsWith(baseName) && ubl.endsWith('.ubl.xml')
|
|
|
|
);
|
|
|
|
|
|
|
|
if (matchingUbl) {
|
|
|
|
pairs.push({ cii: ciiFile, ubl: matchingUbl, name: baseName });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`Found ${pairs.length} CII/UBL pairs for conversion testing`);
|
|
|
|
|
|
|
|
const { EInvoice } = await import('../../../ts/index.js');
|
|
|
|
|
|
|
|
let successCount = 0;
|
|
|
|
const conversionIssues: string[] = [];
|
|
|
|
|
|
|
|
for (const pair of pairs.slice(0, 5)) { // Test first 5 pairs
|
|
|
|
try {
|
|
|
|
// Load CII invoice
|
|
|
|
const ciiBuffer = await fs.readFile(pair.cii, 'utf-8');
|
|
|
|
const ciiInvoice = await EInvoice.fromXml(ciiBuffer);
|
|
|
|
|
|
|
|
// Convert to UBL
|
|
|
|
const { result: ublXml, metric } = await PerformanceTracker.track(
|
|
|
|
'cii-to-ubl-conversion',
|
|
|
|
async () => ciiInvoice.exportXml('ubl' as any),
|
|
|
|
{ file: pair.name }
|
|
|
|
);
|
|
|
|
|
|
|
|
expect(ublXml).toBeTruthy();
|
|
|
|
expect(ublXml).toContain('xmlns:cbc=');
|
|
|
|
expect(ublXml).toContain('xmlns:cac=');
|
|
|
|
|
|
|
|
// Load the converted UBL back
|
|
|
|
const convertedInvoice = await EInvoice.fromXml(ublXml);
|
|
|
|
|
|
|
|
// Verify key fields are preserved
|
|
|
|
verifyFieldMapping(ciiInvoice, convertedInvoice, pair.name);
|
|
|
|
|
|
|
|
successCount++;
|
|
|
|
console.log(`✓ ${pair.name}: CII→UBL conversion successful (${metric.duration.toFixed(2)}ms)`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
const issue = `${pair.name}: ${error.message}`;
|
|
|
|
conversionIssues.push(issue);
|
|
|
|
console.log(`✗ ${issue}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`\nCII→UBL Conversion Summary: ${successCount}/${Math.min(pairs.length, 5)} successful`);
|
|
|
|
if (conversionIssues.length > 0) {
|
|
|
|
console.log('Issues:', conversionIssues.slice(0, 3));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Performance summary
|
|
|
|
const perfSummary = await PerformanceTracker.getSummary('cii-to-ubl-conversion');
|
|
|
|
if (perfSummary) {
|
|
|
|
console.log(`\nCII→UBL Conversion Performance:`);
|
|
|
|
console.log(` Average: ${perfSummary.average.toFixed(2)}ms`);
|
|
|
|
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(successCount).toBeGreaterThan(0);
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('CONV-01: UBL to CII Conversion - should convert UBL invoices to CII format', async () => {
|
|
|
|
const { EInvoice } = await import('../../../ts/index.js');
|
|
|
|
|
|
|
|
const ublFiles = await CorpusLoader.getFiles('UBL_XMLRECHNUNG');
|
|
|
|
const testFiles = ublFiles.filter(f => f.endsWith('.xml')).slice(0, 3);
|
|
|
|
|
|
|
|
console.log(`Testing UBL to CII conversion with ${testFiles.length} files`);
|
|
|
|
|
|
|
|
let successCount = 0;
|
|
|
|
let skipCount = 0;
|
|
|
|
|
|
|
|
for (const filePath of testFiles) {
|
|
|
|
const fileName = path.basename(filePath);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const ublContent = await fs.readFile(filePath, 'utf-8');
|
|
|
|
const ublInvoice = await EInvoice.fromXml(ublContent);
|
|
|
|
|
|
|
|
// Skip if detected as XRechnung (might have special requirements)
|
|
|
|
const format = ublInvoice.getFormat ? ublInvoice.getFormat() : 'unknown';
|
|
|
|
if (format.toString().toLowerCase().includes('xrechnung')) {
|
|
|
|
console.log(`○ ${fileName}: Skipping XRechnung-specific file`);
|
|
|
|
skipCount++;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Convert to CII (Factur-X)
|
|
|
|
const { result: ciiXml, metric } = await PerformanceTracker.track(
|
|
|
|
'ubl-to-cii-conversion',
|
|
|
|
async () => ublInvoice.exportXml('facturx' as any),
|
|
|
|
{ file: fileName }
|
|
|
|
);
|
|
|
|
|
|
|
|
expect(ciiXml).toBeTruthy();
|
|
|
|
expect(ciiXml).toContain('CrossIndustryInvoice');
|
|
|
|
expect(ciiXml).toContain('ExchangedDocument');
|
|
|
|
|
|
|
|
// Verify round-trip
|
|
|
|
const ciiInvoice = await EInvoice.fromXml(ciiXml);
|
|
|
|
expect(ciiInvoice.invoiceId).toEqual(ublInvoice.invoiceId);
|
|
|
|
|
|
|
|
successCount++;
|
|
|
|
console.log(`✓ ${fileName}: UBL→CII conversion successful (${metric.duration.toFixed(2)}ms)`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.log(`✗ ${fileName}: Conversion failed - ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`\nUBL→CII Conversion Summary: ${successCount} successful, ${skipCount} skipped`);
|
|
|
|
|
|
|
|
// Performance summary
|
|
|
|
const perfSummary = await PerformanceTracker.getSummary('ubl-to-cii-conversion');
|
|
|
|
if (perfSummary) {
|
|
|
|
console.log(`\nUBL→CII Conversion Performance:`);
|
|
|
|
console.log(` Average: ${perfSummary.average.toFixed(2)}ms`);
|
|
|
|
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(successCount + skipCount).toBeGreaterThan(0);
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('CONV-01: ZUGFeRD to XRechnung Conversion - should convert ZUGFeRD PDFs to XRechnung', async () => {
|
|
|
|
const { EInvoice } = await import('../../../ts/index.js');
|
|
|
|
|
2025-05-26 10:17:50 +00:00
|
|
|
// Use direct path to find ZUGFeRD v2 PDFs recursively
|
|
|
|
const { exec } = await import('child_process');
|
|
|
|
const { promisify } = await import('util');
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
|
|
|
|
const { stdout } = await execAsync('find test/assets/corpus/ZUGFeRDv2/correct -name "*.pdf" -type f | head -3');
|
|
|
|
const pdfFiles = stdout.trim().split('\n').filter(f => f.length > 0);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
console.log(`Testing ZUGFeRD to XRechnung conversion with ${pdfFiles.length} PDFs`);
|
|
|
|
|
|
|
|
let tested = 0;
|
|
|
|
let successful = 0;
|
|
|
|
|
|
|
|
for (const filePath of pdfFiles) {
|
|
|
|
const fileName = path.basename(filePath);
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Extract from PDF
|
|
|
|
const pdfBuffer = await fs.readFile(filePath);
|
|
|
|
const zugferdInvoice = await EInvoice.fromPdf(pdfBuffer);
|
|
|
|
|
|
|
|
// Convert to XRechnung
|
|
|
|
const { result: xrechnungXml, metric } = await PerformanceTracker.track(
|
|
|
|
'zugferd-to-xrechnung-conversion',
|
|
|
|
async () => zugferdInvoice.exportXml('xrechnung' as any),
|
|
|
|
{ file: fileName }
|
|
|
|
);
|
|
|
|
|
|
|
|
expect(xrechnungXml).toBeTruthy();
|
|
|
|
|
|
|
|
// XRechnung should be UBL format with specific extensions
|
|
|
|
if (xrechnungXml.includes('Invoice xmlns')) {
|
|
|
|
expect(xrechnungXml).toContain('CustomizationID');
|
|
|
|
expect(xrechnungXml).toContain('urn:cen.eu:en16931');
|
|
|
|
}
|
|
|
|
|
|
|
|
tested++;
|
|
|
|
successful++;
|
|
|
|
console.log(`✓ ${fileName}: ZUGFeRD→XRechnung conversion successful (${metric.duration.toFixed(2)}ms)`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
tested++;
|
|
|
|
console.log(`○ ${fileName}: Conversion not available - ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`\nZUGFeRD→XRechnung Conversion Summary: ${successful}/${tested} successful`);
|
|
|
|
|
|
|
|
if (successful === 0 && tested > 0) {
|
|
|
|
console.log('Note: ZUGFeRD to XRechnung conversion may need implementation');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Performance summary
|
|
|
|
const perfSummary = await PerformanceTracker.getSummary('zugferd-to-xrechnung-conversion');
|
|
|
|
if (perfSummary) {
|
|
|
|
console.log(`\nZUGFeRD→XRechnung Conversion Performance:`);
|
|
|
|
console.log(` Average: ${perfSummary.average.toFixed(2)}ms`);
|
|
|
|
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
|
|
|
|
}
|
|
|
|
|
2025-05-26 10:17:50 +00:00
|
|
|
// Skip assertion if no PDF files are available
|
|
|
|
if (pdfFiles.length === 0) {
|
|
|
|
console.log('⚠️ No PDF files available for testing - skipping test');
|
|
|
|
return; // Skip the test
|
|
|
|
}
|
|
|
|
|
2025-05-25 19:45:37 +00:00
|
|
|
expect(tested).toBeGreaterThan(0);
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('CONV-01: Data Preservation During Conversion - should preserve invoice data across formats', async () => {
|
|
|
|
const { EInvoice } = await import('../../../ts/index.js');
|
|
|
|
|
|
|
|
// Create a test invoice with comprehensive data
|
|
|
|
const testInvoice = new EInvoice();
|
|
|
|
testInvoice.id = 'DATA-PRESERVATION-TEST';
|
|
|
|
testInvoice.invoiceId = 'INV-2024-001';
|
|
|
|
testInvoice.date = Date.now();
|
|
|
|
testInvoice.currency = 'EUR';
|
|
|
|
|
|
|
|
testInvoice.from = {
|
|
|
|
name: 'Test Seller GmbH',
|
|
|
|
type: 'company',
|
|
|
|
description: 'Test seller company',
|
|
|
|
address: {
|
|
|
|
streetName: 'Musterstraße',
|
|
|
|
houseNumber: '123',
|
|
|
|
city: 'Berlin',
|
|
|
|
country: 'Germany',
|
|
|
|
postalCode: '10115'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2020, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'DE123456789',
|
|
|
|
registrationId: 'HRB 12345',
|
|
|
|
registrationName: 'Handelsregister Berlin'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
testInvoice.to = {
|
|
|
|
name: 'Test Buyer Ltd',
|
|
|
|
type: 'company',
|
|
|
|
description: 'Test buyer company',
|
|
|
|
address: {
|
|
|
|
streetName: 'Example Street',
|
|
|
|
houseNumber: '456',
|
|
|
|
city: 'London',
|
|
|
|
country: 'United Kingdom',
|
|
|
|
postalCode: 'SW1A 1AA'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2019, month: 6, day: 15 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'GB987654321',
|
|
|
|
registrationId: 'Companies House 87654321',
|
|
|
|
registrationName: 'Companies House'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
testInvoice.items = [
|
|
|
|
{
|
|
|
|
position: 1,
|
|
|
|
name: 'Professional Service',
|
|
|
|
articleNumber: 'SERV-001',
|
|
|
|
unitType: 'HUR',
|
|
|
|
unitQuantity: 8,
|
|
|
|
unitNetPrice: 150,
|
|
|
|
vatPercentage: 19
|
|
|
|
},
|
|
|
|
{
|
|
|
|
position: 2,
|
|
|
|
name: 'Software License',
|
|
|
|
articleNumber: 'SOFT-001',
|
|
|
|
unitType: 'EA',
|
|
|
|
unitQuantity: 1,
|
|
|
|
unitNetPrice: 500,
|
|
|
|
vatPercentage: 19
|
|
|
|
}
|
|
|
|
];
|
|
|
|
|
|
|
|
// Test conversions and check for data preservation
|
|
|
|
const conversions: Array<{from: string, to: string}> = [
|
|
|
|
{ from: 'facturx', to: 'ubl' },
|
|
|
|
{ from: 'facturx', to: 'xrechnung' }
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const conversion of conversions) {
|
|
|
|
console.log(`\nTesting ${conversion.from} → ${conversion.to} data preservation:`);
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Generate source XML
|
|
|
|
const sourceXml = await testInvoice.exportXml(conversion.from as any);
|
|
|
|
await testInvoice.loadXml(sourceXml);
|
|
|
|
|
|
|
|
// Convert to target format
|
|
|
|
const { result: convertedXml, metric } = await PerformanceTracker.track(
|
|
|
|
'data-preservation-conversion',
|
|
|
|
async () => testInvoice.exportXml(conversion.to as any),
|
|
|
|
{ conversion: `${conversion.from}-to-${conversion.to}` }
|
|
|
|
);
|
|
|
|
|
|
|
|
const convertedInvoice = await EInvoice.fromXml(convertedXml);
|
|
|
|
|
|
|
|
// Check for data preservation
|
|
|
|
const issues = checkDataPreservation(testInvoice, convertedInvoice);
|
|
|
|
|
|
|
|
if (issues.length === 0) {
|
|
|
|
console.log(`✓ All critical data preserved (${metric.duration.toFixed(2)}ms)`);
|
|
|
|
} else {
|
|
|
|
console.log(`⚠ Data preservation issues found:`);
|
|
|
|
issues.forEach(issue => console.log(` - ${issue}`));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Core fields should always be preserved
|
|
|
|
expect(convertedInvoice.invoiceId).toEqual(testInvoice.invoiceId);
|
|
|
|
expect(convertedInvoice.from.name).toEqual(testInvoice.from.name);
|
|
|
|
expect(convertedInvoice.to.name).toEqual(testInvoice.to.name);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.log(`✗ Conversion failed: ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('CONV-01: Conversion Performance Benchmarks - should meet conversion performance targets', async () => {
|
|
|
|
console.log('\nConversion Performance Benchmark Summary:');
|
|
|
|
|
|
|
|
const conversionOperations = [
|
|
|
|
'cii-to-ubl-conversion',
|
|
|
|
'ubl-to-cii-conversion',
|
|
|
|
'zugferd-to-xrechnung-conversion'
|
|
|
|
];
|
|
|
|
|
|
|
|
const benchmarkResults: { operation: string; metrics: any }[] = [];
|
|
|
|
|
|
|
|
for (const operation of conversionOperations) {
|
|
|
|
const summary = await PerformanceTracker.getSummary(operation);
|
|
|
|
if (summary) {
|
|
|
|
benchmarkResults.push({ operation, metrics: summary });
|
|
|
|
console.log(`\n${operation}:`);
|
|
|
|
console.log(` Average: ${summary.average.toFixed(2)}ms`);
|
|
|
|
console.log(` P95: ${summary.p95.toFixed(2)}ms`);
|
|
|
|
console.log(` Count: ${summary.min !== undefined ? 'Available' : 'No data'}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (benchmarkResults.length > 0) {
|
|
|
|
const overallAverage = benchmarkResults.reduce((sum, result) =>
|
|
|
|
sum + result.metrics.average, 0) / benchmarkResults.length;
|
|
|
|
|
|
|
|
console.log(`\nOverall Conversion Performance:`);
|
|
|
|
console.log(` Average across operations: ${overallAverage.toFixed(2)}ms`);
|
|
|
|
|
|
|
|
// Performance targets
|
|
|
|
expect(overallAverage).toBeLessThan(1000); // Conversions should be under 1 second on average
|
|
|
|
|
|
|
|
benchmarkResults.forEach(result => {
|
|
|
|
expect(result.metrics.p95).toBeLessThan(2000); // P95 should be under 2 seconds
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(`✓ All conversion performance benchmarks met`);
|
|
|
|
} else {
|
|
|
|
console.log('No conversion performance data available');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Helper function to verify field mapping between invoices
|
|
|
|
function verifyFieldMapping(source: EInvoice, converted: EInvoice, testName: string): void {
|
|
|
|
const criticalFields = [
|
|
|
|
{ field: 'invoiceId', name: 'Invoice ID' },
|
|
|
|
{ field: 'currency', name: 'Currency' }
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const check of criticalFields) {
|
|
|
|
const sourceVal = source[check.field as keyof EInvoice];
|
|
|
|
const convertedVal = converted[check.field as keyof EInvoice];
|
|
|
|
|
|
|
|
if (sourceVal !== convertedVal) {
|
|
|
|
console.log(` ⚠ ${check.name} mismatch: ${sourceVal} → ${convertedVal}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check seller/buyer names
|
|
|
|
if (source.from?.name !== converted.from?.name) {
|
|
|
|
console.log(` ⚠ Seller name mismatch: ${source.from?.name} → ${converted.from?.name}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (source.to?.name !== converted.to?.name) {
|
|
|
|
console.log(` ⚠ Buyer name mismatch: ${source.to?.name} → ${converted.to?.name}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check items count
|
|
|
|
if (source.items?.length !== converted.items?.length) {
|
|
|
|
console.log(` ⚠ Items count mismatch: ${source.items?.length} → ${converted.items?.length}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Helper function to check data preservation
|
|
|
|
function checkDataPreservation(source: EInvoice, converted: EInvoice): string[] {
|
|
|
|
const issues: string[] = [];
|
|
|
|
|
|
|
|
// Check basic fields
|
|
|
|
if (source.invoiceId !== converted.invoiceId) {
|
|
|
|
issues.push(`Invoice ID changed: ${source.invoiceId} → ${converted.invoiceId}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (source.currency !== converted.currency) {
|
|
|
|
issues.push(`Currency changed: ${source.currency} → ${converted.currency}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check party information
|
|
|
|
if (source.from?.name !== converted.from?.name) {
|
|
|
|
issues.push(`Seller name changed: ${source.from?.name} → ${converted.from?.name}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (source.to?.name !== converted.to?.name) {
|
|
|
|
issues.push(`Buyer name changed: ${source.to?.name} → ${converted.to?.name}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check items
|
|
|
|
if (source.items?.length !== converted.items?.length) {
|
|
|
|
issues.push(`Items count changed: ${source.items?.length} → ${converted.items?.length}`);
|
|
|
|
} else if (source.items && converted.items) {
|
|
|
|
for (let i = 0; i < source.items.length; i++) {
|
|
|
|
const sourceItem = source.items[i];
|
|
|
|
const convertedItem = converted.items[i];
|
|
|
|
|
|
|
|
if (sourceItem.name !== convertedItem.name) {
|
|
|
|
issues.push(`Item ${i+1} name changed: ${sourceItem.name} → ${convertedItem.name}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (sourceItem.unitNetPrice !== convertedItem.unitNetPrice) {
|
|
|
|
issues.push(`Item ${i+1} price changed: ${sourceItem.unitNetPrice} → ${convertedItem.unitNetPrice}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return issues;
|
|
|
|
}
|
|
|
|
|
|
|
|
tap.start();
|