2025-05-26 05:16:32 +00:00
|
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
2025-05-24 16:33:58 +00:00
|
|
|
import { EInvoice, EInvoiceFormatError } from '../ts/index.js';
|
|
|
|
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
2025-05-30 18:18:42 +00:00
|
|
|
import { TestFileHelpers, TestFileCategories, PerformanceUtils, TestInvoiceFactory } from './helpers/utils.js';
|
2025-05-24 16:33:58 +00:00
|
|
|
import * as path from 'path';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Cross-format conversion test suite
|
|
|
|
*/
|
|
|
|
|
|
|
|
// Test conversion between CII and UBL using paired files
|
|
|
|
tap.test('Conversion - CII to UBL using XML-Rechnung pairs', async () => {
|
|
|
|
// Get matching CII and UBL files
|
|
|
|
const ciiFiles = await TestFileHelpers.getTestFiles(TestFileCategories.CII_XMLRECHNUNG, '*.xml');
|
|
|
|
const ublFiles = await TestFileHelpers.getTestFiles(TestFileCategories.UBL_XMLRECHNUNG, '*.xml');
|
|
|
|
|
|
|
|
// 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`);
|
|
|
|
|
|
|
|
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 TestFileHelpers.loadTestFile(pair.cii);
|
|
|
|
const ciiInvoice = await EInvoice.fromXml(ciiBuffer.toString('utf-8'));
|
|
|
|
|
|
|
|
// Convert to UBL
|
|
|
|
const { result: ublXml, duration } = await PerformanceUtils.measure(
|
|
|
|
'cii-to-ubl',
|
|
|
|
async () => ciiInvoice.exportXml('ubl')
|
|
|
|
);
|
|
|
|
|
|
|
|
expect(ublXml).toBeTruthy();
|
|
|
|
expect(ublXml).toInclude('xmlns:cbc=');
|
|
|
|
expect(ublXml).toInclude('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 (${duration.toFixed(2)}ms)`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
const issue = `${pair.name}: ${error.message}`;
|
|
|
|
conversionIssues.push(issue);
|
|
|
|
console.log(`✗ ${issue}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`\nConversion Summary: ${successCount}/${pairs.length} successful`);
|
|
|
|
if (conversionIssues.length > 0) {
|
|
|
|
console.log('Issues:', conversionIssues);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test conversion from UBL to CII
|
|
|
|
tap.test('Conversion - UBL to CII reverse conversion', async () => {
|
|
|
|
const ublFiles = await TestFileHelpers.getTestFiles(TestFileCategories.UBL_XMLRECHNUNG, '*.xml');
|
|
|
|
console.log(`Testing UBL to CII conversion with ${ublFiles.length} files`);
|
|
|
|
|
|
|
|
for (const file of ublFiles.slice(0, 3)) {
|
|
|
|
const fileName = path.basename(file);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const ublBuffer = await TestFileHelpers.loadTestFile(file);
|
|
|
|
const ublInvoice = await EInvoice.fromXml(ublBuffer.toString('utf-8'));
|
|
|
|
|
|
|
|
// Skip if detected as XRechnung (might have special requirements)
|
|
|
|
if (ublInvoice.getFormat() === InvoiceFormat.XRECHNUNG) {
|
|
|
|
console.log(`○ ${fileName}: Skipping XRechnung-specific file`);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Convert to CII (Factur-X)
|
|
|
|
const ciiXml = await ublInvoice.exportXml('facturx');
|
|
|
|
|
|
|
|
expect(ciiXml).toBeTruthy();
|
|
|
|
expect(ciiXml).toInclude('CrossIndustryInvoice');
|
|
|
|
expect(ciiXml).toInclude('ExchangedDocument');
|
|
|
|
|
|
|
|
// Verify round-trip
|
|
|
|
const ciiInvoice = await EInvoice.fromXml(ciiXml);
|
|
|
|
expect(ciiInvoice.invoiceId).toEqual(ublInvoice.invoiceId);
|
|
|
|
|
|
|
|
console.log(`✓ ${fileName}: UBL→CII conversion successful`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
if (error instanceof EInvoiceFormatError) {
|
|
|
|
console.log(`✗ ${fileName}: Format error - ${error.message}`);
|
|
|
|
if (error.unsupportedFeatures) {
|
|
|
|
console.log(` Unsupported features: ${error.unsupportedFeatures.join(', ')}`);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
console.log(`✗ ${fileName}: ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test ZUGFeRD to XRechnung conversion
|
|
|
|
tap.test('Conversion - ZUGFeRD to XRechnung format', async () => {
|
|
|
|
const zugferdPdfs = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V2_CORRECT, '*.pdf');
|
|
|
|
|
|
|
|
let tested = 0;
|
|
|
|
for (const file of zugferdPdfs.slice(0, 3)) {
|
|
|
|
const fileName = path.basename(file);
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Extract from PDF
|
|
|
|
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
|
|
|
|
const zugferdInvoice = await EInvoice.fromPdf(pdfBuffer);
|
|
|
|
|
|
|
|
// Convert to XRechnung
|
|
|
|
const xrechnungXml = await zugferdInvoice.exportXml('xrechnung');
|
|
|
|
|
|
|
|
expect(xrechnungXml).toBeTruthy();
|
|
|
|
|
|
|
|
// XRechnung should be UBL format with specific extensions
|
|
|
|
if (xrechnungXml.includes('Invoice xmlns')) {
|
|
|
|
expect(xrechnungXml).toInclude('CustomizationID');
|
|
|
|
expect(xrechnungXml).toInclude('urn:cen.eu:en16931');
|
|
|
|
}
|
|
|
|
|
|
|
|
tested++;
|
|
|
|
console.log(`✓ ${fileName}: ZUGFeRD→XRechnung conversion successful`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.log(`○ ${fileName}: Conversion not available - ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (tested === 0) {
|
|
|
|
console.log('Note: ZUGFeRD to XRechnung conversion may need implementation');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test data loss detection during conversion
|
|
|
|
tap.test('Conversion - Data loss detection and reporting', async () => {
|
|
|
|
// Create a complex invoice with all possible fields
|
|
|
|
const complexInvoice = new EInvoice();
|
|
|
|
Object.assign(complexInvoice, TestInvoiceFactory.createComplexInvoice());
|
|
|
|
|
|
|
|
// Add format-specific fields
|
|
|
|
complexInvoice.buyerReference = 'PO-2024-12345';
|
|
|
|
complexInvoice.electronicAddress = {
|
|
|
|
scheme: '0088',
|
|
|
|
value: '1234567890123'
|
|
|
|
};
|
|
|
|
complexInvoice.notes = [
|
|
|
|
'Special handling required',
|
|
|
|
'Express delivery requested',
|
|
|
|
'Contact buyer before delivery'
|
|
|
|
];
|
|
|
|
|
|
|
|
// Generate source XML
|
|
|
|
const sourceXml = await complexInvoice.exportXml('facturx');
|
|
|
|
await complexInvoice.loadXml(sourceXml);
|
|
|
|
|
|
|
|
// Test conversions and check for data loss
|
|
|
|
const formats: Array<{from: string, to: string}> = [
|
|
|
|
{ from: 'facturx', to: 'ubl' },
|
|
|
|
{ from: 'facturx', to: 'xrechnung' },
|
|
|
|
{ from: 'facturx', to: 'zugferd' }
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const conversion of formats) {
|
|
|
|
console.log(`\nTesting ${conversion.from} → ${conversion.to} conversion:`);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const convertedXml = await complexInvoice.exportXml(conversion.to as any);
|
|
|
|
const convertedInvoice = await EInvoice.fromXml(convertedXml);
|
|
|
|
|
|
|
|
// Check for data preservation
|
|
|
|
const issues = checkDataPreservation(complexInvoice, convertedInvoice);
|
|
|
|
|
|
|
|
if (issues.length === 0) {
|
|
|
|
console.log(`✓ All data preserved in ${conversion.to} format`);
|
|
|
|
} else {
|
|
|
|
console.log(`⚠ Data loss detected in ${conversion.to} format:`);
|
|
|
|
issues.forEach(issue => console.log(` - ${issue}`));
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.log(`✗ Conversion failed: ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test edge cases in conversion
|
|
|
|
tap.test('Conversion - Edge cases and special characters', async () => {
|
|
|
|
const edgeCaseInvoice = new EInvoice();
|
|
|
|
Object.assign(edgeCaseInvoice, TestInvoiceFactory.createMinimalInvoice());
|
|
|
|
|
|
|
|
// Add edge case data
|
|
|
|
edgeCaseInvoice.from.name = 'Müller & Söhne GmbH & Co. KG';
|
|
|
|
edgeCaseInvoice.to.name = 'L\'Entreprise Française S.à.r.l.';
|
|
|
|
edgeCaseInvoice.items[0].name = 'Product with "quotes" and <tags>';
|
|
|
|
edgeCaseInvoice.notes = ['Note with € symbol', 'Japanese: こんにちは'];
|
|
|
|
|
|
|
|
// Test conversion with special characters
|
|
|
|
const formats = ['facturx', 'ubl', 'xrechnung'] as const;
|
|
|
|
|
|
|
|
for (const format of formats) {
|
|
|
|
try {
|
|
|
|
const xml = await edgeCaseInvoice.exportXml(format);
|
|
|
|
|
|
|
|
// Verify special characters are properly encoded
|
|
|
|
expect(xml).toInclude('Müller');
|
|
|
|
expect(xml).toInclude('Française');
|
|
|
|
expect(xml).toContain('"'); // Encoded quotes
|
|
|
|
expect(xml).toContain('<'); // Encoded less-than
|
|
|
|
|
|
|
|
// Verify it can be parsed back
|
|
|
|
const parsed = await EInvoice.fromXml(xml);
|
|
|
|
expect(parsed.from.name).toEqual('Müller & Söhne GmbH & Co. KG');
|
|
|
|
|
|
|
|
console.log(`✓ ${format}: Special characters handled correctly`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.log(`✗ ${format}: Failed with special characters - ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test batch conversion performance
|
|
|
|
tap.test('Conversion - Batch conversion performance', async () => {
|
|
|
|
const files = await TestFileHelpers.getTestFiles(TestFileCategories.UBL_XMLRECHNUNG, '*.xml');
|
|
|
|
const batchSize = Math.min(10, files.length);
|
|
|
|
|
|
|
|
console.log(`Testing batch conversion of ${batchSize} files`);
|
|
|
|
|
|
|
|
const startTime = performance.now();
|
|
|
|
const results = await Promise.all(
|
|
|
|
files.slice(0, batchSize).map(async (file) => {
|
|
|
|
try {
|
|
|
|
const buffer = await TestFileHelpers.loadTestFile(file);
|
|
|
|
const invoice = await EInvoice.fromXml(buffer.toString('utf-8'));
|
|
|
|
const converted = await invoice.exportXml('facturx');
|
|
|
|
return { success: true, size: converted.length };
|
|
|
|
} catch (error) {
|
|
|
|
return { success: false, error: error.message };
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
|
|
|
|
const duration = performance.now() - startTime;
|
|
|
|
const successCount = results.filter(r => r.success).length;
|
|
|
|
|
|
|
|
console.log(`✓ Batch conversion completed in ${duration.toFixed(2)}ms`);
|
|
|
|
console.log(` Success rate: ${successCount}/${batchSize}`);
|
|
|
|
console.log(` Average time per conversion: ${(duration / batchSize).toFixed(2)}ms`);
|
|
|
|
|
|
|
|
expect(duration / batchSize).toBeLessThan(500); // Should be under 500ms per conversion
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test format-specific extensions preservation
|
|
|
|
tap.test('Conversion - Format-specific extensions', async () => {
|
|
|
|
// This tests that format-specific extensions don't break conversion
|
|
|
|
const extensionTests = [
|
|
|
|
{
|
|
|
|
name: 'XRechnung BuyerReference',
|
|
|
|
xml: `<?xml version="1.0"?>
|
|
|
|
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
|
|
|
<CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xrechnung:cius:2.0</CustomizationID>
|
|
|
|
<ID>123</ID>
|
|
|
|
<BuyerReference>04011000-12345-03</BuyerReference>
|
|
|
|
</Invoice>`
|
|
|
|
}
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const test of extensionTests) {
|
|
|
|
try {
|
|
|
|
const invoice = await EInvoice.fromXml(test.xml);
|
|
|
|
|
|
|
|
// Convert to CII
|
|
|
|
const ciiXml = await invoice.exportXml('facturx');
|
|
|
|
expect(ciiXml).toBeTruthy();
|
|
|
|
|
|
|
|
// Convert back to UBL
|
|
|
|
const ciiInvoice = await EInvoice.fromXml(ciiXml);
|
|
|
|
const ublXml = await ciiInvoice.exportXml('ubl');
|
|
|
|
|
|
|
|
// Check if buyer reference survived
|
|
|
|
if (invoice.buyerReference) {
|
|
|
|
expect(ublXml).toInclude(invoice.buyerReference);
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`✓ ${test.name}: Extension preserved through conversion`);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.log(`✗ ${test.name}: ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test conversion error handling
|
|
|
|
tap.test('Conversion - Error handling and recovery', async () => {
|
|
|
|
// Test with minimal invalid invoice
|
|
|
|
const invalidInvoice = new EInvoice();
|
|
|
|
invalidInvoice.id = 'TEST-INVALID';
|
|
|
|
// Missing required fields like from, to, items
|
|
|
|
|
|
|
|
try {
|
|
|
|
await invalidInvoice.exportXml('facturx');
|
2025-05-30 18:18:42 +00:00
|
|
|
throw new Error('Should have thrown an error for invalid invoice');
|
2025-05-24 16:33:58 +00:00
|
|
|
} catch (error) {
|
|
|
|
console.log(`✓ Invalid invoice error caught: ${error.message}`);
|
|
|
|
|
|
|
|
if (error instanceof EInvoiceFormatError) {
|
|
|
|
console.log(` Compatibility report:\n${error.getCompatibilityReport()}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Performance summary for conversions
|
|
|
|
tap.test('Conversion - Performance Summary', async () => {
|
|
|
|
const conversionStats = PerformanceUtils.getStats('cii-to-ubl');
|
|
|
|
|
|
|
|
if (conversionStats) {
|
|
|
|
console.log('\nConversion Performance:');
|
|
|
|
console.log(`CII to UBL conversions: ${conversionStats.count}`);
|
|
|
|
console.log(`Average time: ${conversionStats.avg.toFixed(2)}ms`);
|
|
|
|
console.log(`Min/Max: ${conversionStats.min.toFixed(2)}ms / ${conversionStats.max.toFixed(2)}ms`);
|
|
|
|
|
|
|
|
// Conversions should be reasonably fast
|
|
|
|
expect(conversionStats.avg).toBeLessThan(100);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Helper function to verify field mapping
|
|
|
|
function verifyFieldMapping(source: EInvoice, converted: EInvoice, testName: string): void {
|
|
|
|
const criticalFields = [
|
|
|
|
{ field: 'invoiceId', name: 'Invoice ID' },
|
|
|
|
{ field: 'date', name: 'Invoice Date' },
|
|
|
|
{ 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
|
|
|
|
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.buyerReference && source.buyerReference !== converted.buyerReference) {
|
|
|
|
issues.push(`Buyer reference lost or changed`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (source.notes && source.notes.length !== converted.notes?.length) {
|
|
|
|
issues.push(`Notes count changed: ${source.notes.length} → ${converted.notes?.length || 0}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (source.electronicAddress && !converted.electronicAddress) {
|
|
|
|
issues.push(`Electronic address lost`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check payment details
|
|
|
|
if (source.paymentOptions?.sepaConnection?.iban !== converted.paymentOptions?.sepaConnection?.iban) {
|
|
|
|
issues.push(`IBAN changed or lost`);
|
|
|
|
}
|
|
|
|
|
|
|
|
return issues;
|
|
|
|
}
|
|
|
|
|
|
|
|
tap.start();
|