BREAKING CHANGE(core): Rebrand XInvoice to EInvoice: update package name, class names, imports, and documentation
This commit is contained in:
370
test/test-utils.ts
Normal file
370
test/test-utils.ts
Normal file
@ -0,0 +1,370 @@
|
||||
import * as path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import type { TInvoice } from '../ts/interfaces/common.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import { business, finance } from '../ts/plugins.js';
|
||||
|
||||
/**
|
||||
* Test utilities for EInvoice testing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Test file categories based on the corpus
|
||||
*/
|
||||
export const TestFileCategories = {
|
||||
CII_XMLRECHNUNG: 'test/assets/corpus/XML-Rechnung/CII',
|
||||
UBL_XMLRECHNUNG: 'test/assets/corpus/XML-Rechnung/UBL',
|
||||
ZUGFERD_V1_CORRECT: 'test/assets/corpus/ZUGFeRDv1/correct',
|
||||
ZUGFERD_V1_FAIL: 'test/assets/corpus/ZUGFeRDv1/fail',
|
||||
ZUGFERD_V2_CORRECT: 'test/assets/corpus/ZUGFeRDv2/correct',
|
||||
ZUGFERD_V2_FAIL: 'test/assets/corpus/ZUGFeRDv2/fail',
|
||||
PEPPOL: 'test/assets/corpus/PEPPOL/Valid/Qvalia',
|
||||
FATTURAPA: 'test/assets/corpus/fatturaPA',
|
||||
EN16931_UBL_INVOICE: 'test/assets/eInvoicing-EN16931/test/Invoice-unit-UBL',
|
||||
EN16931_UBL_CREDITNOTE: 'test/assets/eInvoicing-EN16931/test/CreditNote-unit-UBL',
|
||||
EN16931_EXAMPLES_CII: 'test/assets/eInvoicing-EN16931/cii/examples',
|
||||
EN16931_EXAMPLES_UBL: 'test/assets/eInvoicing-EN16931/ubl/examples',
|
||||
EN16931_EXAMPLES_EDIFACT: 'test/assets/eInvoicing-EN16931/edifact/examples'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Test data factory for creating test invoices
|
||||
*/
|
||||
export class TestInvoiceFactory {
|
||||
/**
|
||||
* Creates a minimal valid test invoice
|
||||
*/
|
||||
static createMinimalInvoice(): Partial<TInvoice> {
|
||||
return {
|
||||
id: 'TEST-' + Date.now(),
|
||||
invoiceId: 'INV-TEST-001',
|
||||
invoiceType: 'debitnote',
|
||||
type: 'invoice',
|
||||
date: Date.now(),
|
||||
status: 'draft',
|
||||
subject: 'Test Invoice',
|
||||
from: {
|
||||
name: 'Test Seller Company',
|
||||
type: 'company',
|
||||
description: 'Test seller',
|
||||
address: {
|
||||
streetName: 'Test Street',
|
||||
houseNumber: '1',
|
||||
city: 'Test City',
|
||||
country: 'Germany',
|
||||
postalCode: '12345'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: { year: 2020, month: 1, day: 1 },
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789',
|
||||
registrationId: 'HRB 12345',
|
||||
registrationName: 'Test Registry'
|
||||
}
|
||||
},
|
||||
to: {
|
||||
name: 'Test Buyer Company',
|
||||
type: 'company',
|
||||
description: 'Test buyer',
|
||||
address: {
|
||||
streetName: 'Buyer Street',
|
||||
houseNumber: '2',
|
||||
city: 'Buyer City',
|
||||
country: 'France',
|
||||
postalCode: '75001'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: { year: 2019, month: 6, day: 15 },
|
||||
registrationDetails: {
|
||||
vatId: 'FR987654321',
|
||||
registrationId: 'RCS 98765',
|
||||
registrationName: 'French Registry'
|
||||
}
|
||||
},
|
||||
items: [{
|
||||
position: 1,
|
||||
name: 'Test Product',
|
||||
articleNumber: 'TEST-001',
|
||||
unitType: 'EA',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19
|
||||
}],
|
||||
currency: 'EUR',
|
||||
language: 'en',
|
||||
objectActions: [],
|
||||
versionInfo: {
|
||||
type: 'draft',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complex test invoice with multiple items and features
|
||||
*/
|
||||
static createComplexInvoice(): Partial<TInvoice> {
|
||||
const baseInvoice = this.createMinimalInvoice();
|
||||
return {
|
||||
...baseInvoice,
|
||||
items: [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Professional Service',
|
||||
articleNumber: 'SERV-001',
|
||||
unitType: 'HUR',
|
||||
unitQuantity: 8,
|
||||
unitNetPrice: 150,
|
||||
vatPercentage: 19,
|
||||
// description: 'Consulting services'
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
name: 'Software License',
|
||||
articleNumber: 'SOFT-001',
|
||||
unitType: 'EA',
|
||||
unitQuantity: 5,
|
||||
unitNetPrice: 200,
|
||||
vatPercentage: 19,
|
||||
// description: 'Annual software license'
|
||||
},
|
||||
{
|
||||
position: 3,
|
||||
name: 'Training',
|
||||
articleNumber: 'TRAIN-001',
|
||||
unitType: 'DAY',
|
||||
unitQuantity: 2,
|
||||
unitNetPrice: 800,
|
||||
vatPercentage: 19,
|
||||
// description: 'On-site training'
|
||||
}
|
||||
],
|
||||
paymentOptions: {
|
||||
description: 'Payment due within 30 days',
|
||||
sepaConnection: {
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'COBADEFFXXX'
|
||||
},
|
||||
payPal: { email: 'test@example.com' }
|
||||
},
|
||||
notes: [
|
||||
'This is a test invoice for validation purposes',
|
||||
'All amounts are in EUR'
|
||||
],
|
||||
periodOfPerformance: {
|
||||
from: Date.now() - 30 * 24 * 60 * 60 * 1000, // 30 days ago
|
||||
to: Date.now()
|
||||
},
|
||||
deliveryDate: Date.now(),
|
||||
buyerReference: 'PO-2024-001',
|
||||
dueInDays: 30,
|
||||
reverseCharge: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test file helpers
|
||||
*/
|
||||
export class TestFileHelpers {
|
||||
/**
|
||||
* Gets all test files from a directory
|
||||
*/
|
||||
static async getTestFiles(directory: string, pattern: string = '*'): Promise<string[]> {
|
||||
const basePath = path.join(process.cwd(), directory);
|
||||
const files: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(basePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
const fileName = entry.name;
|
||||
if (pattern === '*' || fileName.match(pattern.replace('*', '.*'))) {
|
||||
files.push(path.join(directory, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${basePath}:`, error);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a test file
|
||||
*/
|
||||
static async loadTestFile(filePath: string): Promise<Buffer> {
|
||||
const fullPath = path.join(process.cwd(), filePath);
|
||||
return fs.readFile(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets corpus statistics
|
||||
*/
|
||||
static async getCorpusStats(): Promise<{
|
||||
totalFiles: number;
|
||||
byFormat: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
}> {
|
||||
const stats = {
|
||||
totalFiles: 0,
|
||||
byFormat: {} as Record<string, number>,
|
||||
byCategory: {} as Record<string, number>
|
||||
};
|
||||
|
||||
for (const [category, path] of Object.entries(TestFileCategories)) {
|
||||
const files = await this.getTestFiles(path, '*.xml');
|
||||
const pdfFiles = await this.getTestFiles(path, '*.pdf');
|
||||
|
||||
const totalCategoryFiles = files.length + pdfFiles.length;
|
||||
stats.totalFiles += totalCategoryFiles;
|
||||
stats.byCategory[category] = totalCategoryFiles;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test assertions for invoice validation
|
||||
*/
|
||||
export class InvoiceAssertions {
|
||||
/**
|
||||
* Asserts that an invoice has all required fields
|
||||
*/
|
||||
static assertRequiredFields(invoice: EInvoice): void {
|
||||
const requiredFields = ['id', 'invoiceId', 'from', 'to', 'items', 'date'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!invoice[field as keyof EInvoice]) {
|
||||
throw new Error(`Required field '${field}' is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested required fields
|
||||
if (!invoice.from.name || !invoice.from.address) {
|
||||
throw new Error('Seller information incomplete');
|
||||
}
|
||||
|
||||
if (!invoice.to.name || !invoice.to.address) {
|
||||
throw new Error('Buyer information incomplete');
|
||||
}
|
||||
|
||||
if (!invoice.items || invoice.items.length === 0) {
|
||||
throw new Error('Invoice must have at least one item');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that format detection works correctly
|
||||
*/
|
||||
static assertFormatDetection(
|
||||
detectedFormat: InvoiceFormat,
|
||||
expectedFormat: InvoiceFormat,
|
||||
filePath: string
|
||||
): void {
|
||||
if (detectedFormat !== expectedFormat) {
|
||||
throw new Error(
|
||||
`Format detection failed for ${filePath}: expected ${expectedFormat}, got ${detectedFormat}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts validation results
|
||||
*/
|
||||
static assertValidationResult(
|
||||
result: { valid: boolean; errors: any[] },
|
||||
expectedValid: boolean,
|
||||
filePath: string
|
||||
): void {
|
||||
if (result.valid !== expectedValid) {
|
||||
const errorMessages = result.errors.map(e => e.message).join(', ');
|
||||
throw new Error(
|
||||
`Validation result mismatch for ${filePath}: expected ${expectedValid}, got ${result.valid}. Errors: ${errorMessages}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance testing utilities
|
||||
*/
|
||||
export class PerformanceUtils {
|
||||
private static measurements = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Measures execution time of an async function
|
||||
*/
|
||||
static async measure<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<{ result: T; duration: number }> {
|
||||
const start = performance.now();
|
||||
const result = await fn();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Store measurement
|
||||
if (!this.measurements.has(name)) {
|
||||
this.measurements.set(name, []);
|
||||
}
|
||||
this.measurements.get(name)!.push(duration);
|
||||
|
||||
return { result, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets performance statistics
|
||||
*/
|
||||
static getStats(name: string): {
|
||||
count: number;
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
median: number;
|
||||
} | null {
|
||||
const measurements = this.measurements.get(name);
|
||||
if (!measurements || measurements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...measurements].sort((a, b) => a - b);
|
||||
const sum = sorted.reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
count: sorted.length,
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
avg: sum / sorted.length,
|
||||
median: sorted[Math.floor(sorted.length / 2)]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all measurements
|
||||
*/
|
||||
static clear(): void {
|
||||
this.measurements.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a performance report
|
||||
*/
|
||||
static generateReport(): string {
|
||||
let report = 'Performance Report\n==================\n\n';
|
||||
|
||||
for (const [name] of this.measurements) {
|
||||
const stats = this.getStats(name);
|
||||
if (stats) {
|
||||
report += `${name}:\n`;
|
||||
report += ` Executions: ${stats.count}\n`;
|
||||
report += ` Min: ${stats.min.toFixed(2)}ms\n`;
|
||||
report += ` Max: ${stats.max.toFixed(2)}ms\n`;
|
||||
report += ` Avg: ${stats.avg.toFixed(2)}ms\n`;
|
||||
report += ` Median: ${stats.median.toFixed(2)}ms\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test circular export/import of corpus files
|
||||
tap.test('XInvoice should maintain data integrity through export/import cycle', async () => {
|
||||
tap.test('EInvoice should maintain data integrity through export/import cycle', async () => {
|
||||
// Get a subset of files for circular testing
|
||||
const ciiFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII'), '.xml', 3);
|
||||
const ublFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/UBL'), '.xml', 3);
|
||||
@ -66,20 +66,20 @@ async function testCircular(files: string[], exportFormat: string): Promise<{ su
|
||||
// Read the file
|
||||
const xmlContent = await fs.readFile(file, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Export to XML
|
||||
const exportedXml = await xinvoice.exportXml(exportFormat as any);
|
||||
const exportedXml = await einvoice.exportXml(exportFormat as any);
|
||||
|
||||
// Create a new XInvoice from the exported XML
|
||||
const reimportedXInvoice = await XInvoice.fromXml(exportedXml);
|
||||
// Create a new EInvoice from the exported XML
|
||||
const reimportedEInvoice = await EInvoice.fromXml(exportedXml);
|
||||
|
||||
// Check that key properties match
|
||||
const keysMatch =
|
||||
reimportedXInvoice.from.name === xinvoice.from.name &&
|
||||
reimportedXInvoice.to.name === xinvoice.to.name &&
|
||||
reimportedXInvoice.items.length === xinvoice.items.length;
|
||||
reimportedEInvoice.from.name === einvoice.from.name &&
|
||||
reimportedEInvoice.to.name === einvoice.to.name &&
|
||||
reimportedEInvoice.items.length === einvoice.items.length;
|
||||
|
||||
if (keysMatch) {
|
||||
// Success
|
||||
|
409
test/test.conversion.ts
Normal file
409
test/test.conversion.ts
Normal file
@ -0,0 +1,409 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { EInvoice, EInvoiceFormatError } from '../ts/index.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import { TestFileHelpers, TestFileCategories, PerformanceUtils, TestInvoiceFactory } from './test-utils.js';
|
||||
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');
|
||||
expect.fail('Should have thrown an error for invalid invoice');
|
||||
} 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();
|
@ -13,7 +13,7 @@ tap.test('Run all corpus tests', async () => {
|
||||
// Generate a summary report from existing results
|
||||
try {
|
||||
// Create a simple summary
|
||||
const summary = `# XInvoice Corpus Testing Summary
|
||||
const summary = `# EInvoice Corpus Testing Summary
|
||||
|
||||
Generated on: ${new Date().toISOString()}
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { ValidationLevel } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test for XInvoice class functionality
|
||||
tap.test('XInvoice should load XML correctly', async () => {
|
||||
// Test for EInvoice class functionality
|
||||
tap.test('EInvoice should load XML correctly', async () => {
|
||||
// Create a sample XML string
|
||||
const sampleXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
@ -68,16 +68,16 @@ tap.test('XInvoice should load XML correctly', async () => {
|
||||
const xmlPath = path.join(testDir, 'sample-invoice.xml');
|
||||
await fs.writeFile(xmlPath, sampleXml);
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(sampleXml);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(sampleXml);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
expect(xinvoice.id).toEqual('INV-2023-001');
|
||||
expect(xinvoice.from.name).toEqual('Supplier Company');
|
||||
expect(xinvoice.to.name).toEqual('Customer Company');
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
expect(einvoice.id).toEqual('INV-2023-001');
|
||||
expect(einvoice.from.name).toEqual('Supplier Company');
|
||||
expect(einvoice.to.name).toEqual('Customer Company');
|
||||
});
|
||||
|
||||
tap.test('XInvoice should export XML correctly', async () => {
|
||||
tap.test('EInvoice should export XML correctly', async () => {
|
||||
// Create a sample XML string
|
||||
const sampleXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
@ -134,11 +134,11 @@ tap.test('XInvoice should export XML correctly', async () => {
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(sampleXml);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(sampleXml);
|
||||
|
||||
// Export XML
|
||||
const exportedXml = await xinvoice.exportXml('facturx');
|
||||
const exportedXml = await einvoice.exportXml('facturx');
|
||||
|
||||
// Check that the exported XML contains expected elements
|
||||
expect(exportedXml).toInclude('CrossIndustryInvoice');
|
@ -1,31 +1,31 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { ValidationLevel } from '../ts/interfaces/common.js';
|
||||
import type { ExportFormat } from '../ts/interfaces/common.js';
|
||||
|
||||
// Basic XInvoice tests
|
||||
tap.test('XInvoice should have the correct default properties', async () => {
|
||||
const xinvoice = new XInvoice();
|
||||
// Basic EInvoice tests
|
||||
tap.test('EInvoice should have the correct default properties', async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
expect(xinvoice.type).toEqual('invoice');
|
||||
expect(xinvoice.invoiceType).toEqual('debitnote');
|
||||
expect(xinvoice.status).toEqual('invoice');
|
||||
expect(xinvoice.from).toBeTruthy();
|
||||
expect(xinvoice.to).toBeTruthy();
|
||||
expect(xinvoice.items).toBeArray();
|
||||
expect(xinvoice.currency).toEqual('EUR');
|
||||
expect(einvoice.type).toEqual('invoice');
|
||||
expect(einvoice.invoiceType).toEqual('debitnote');
|
||||
expect(einvoice.status).toEqual('invoice');
|
||||
expect(einvoice.from).toBeTruthy();
|
||||
expect(einvoice.to).toBeTruthy();
|
||||
expect(einvoice.items).toBeArray();
|
||||
expect(einvoice.currency).toEqual('EUR');
|
||||
});
|
||||
|
||||
// Test XML export functionality
|
||||
tap.test('XInvoice should export XML in the correct format', async () => {
|
||||
const xinvoice = new XInvoice();
|
||||
xinvoice.id = 'TEST-XML-EXPORT';
|
||||
xinvoice.invoiceId = 'TEST-XML-EXPORT';
|
||||
xinvoice.from.name = 'Test Seller';
|
||||
xinvoice.to.name = 'Test Buyer';
|
||||
tap.test('EInvoice should export XML in the correct format', async () => {
|
||||
const einvoice = new EInvoice();
|
||||
einvoice.id = 'TEST-XML-EXPORT';
|
||||
einvoice.invoiceId = 'TEST-XML-EXPORT';
|
||||
einvoice.from.name = 'Test Seller';
|
||||
einvoice.to.name = 'Test Buyer';
|
||||
|
||||
// Add an item
|
||||
xinvoice.items.push({
|
||||
einvoice.items.push({
|
||||
position: 1,
|
||||
name: 'Test Product',
|
||||
articleNumber: 'TP-001',
|
||||
@ -36,7 +36,7 @@ tap.test('XInvoice should export XML in the correct format', async () => {
|
||||
});
|
||||
|
||||
// Export as Factur-X
|
||||
const xml = await xinvoice.exportXml('facturx');
|
||||
const xml = await einvoice.exportXml('facturx');
|
||||
|
||||
// Check that the XML contains the expected elements
|
||||
expect(xml).toInclude('CrossIndustryInvoice');
|
||||
@ -47,7 +47,7 @@ tap.test('XInvoice should export XML in the correct format', async () => {
|
||||
});
|
||||
|
||||
// Test XML loading functionality
|
||||
tap.test('XInvoice should load XML correctly', async () => {
|
||||
tap.test('EInvoice should load XML correctly', async () => {
|
||||
// Create a sample XML string
|
||||
const sampleXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
@ -94,20 +94,20 @@ tap.test('XInvoice should load XML correctly', async () => {
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(sampleXml);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(sampleXml);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
expect(xinvoice.id).toEqual('TEST-XML-LOAD');
|
||||
expect(xinvoice.from.name).toEqual('XML Seller');
|
||||
expect(xinvoice.to.name).toEqual('XML Buyer');
|
||||
expect(xinvoice.currency).toEqual('EUR');
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
expect(einvoice.id).toEqual('TEST-XML-LOAD');
|
||||
expect(einvoice.from.name).toEqual('XML Seller');
|
||||
expect(einvoice.to.name).toEqual('XML Buyer');
|
||||
expect(einvoice.currency).toEqual('EUR');
|
||||
});
|
||||
|
||||
// Test circular encoding/decoding
|
||||
tap.test('XInvoice should maintain data integrity through export/import cycle', async () => {
|
||||
tap.test('EInvoice should maintain data integrity through export/import cycle', async () => {
|
||||
// Create a sample invoice
|
||||
const originalInvoice = new XInvoice();
|
||||
const originalInvoice = new EInvoice();
|
||||
originalInvoice.id = 'TEST-CIRCULAR';
|
||||
originalInvoice.invoiceId = 'TEST-CIRCULAR';
|
||||
originalInvoice.from.name = 'Circular Seller';
|
||||
@ -127,8 +127,8 @@ tap.test('XInvoice should maintain data integrity through export/import cycle',
|
||||
// Export as Factur-X
|
||||
const xml = await originalInvoice.exportXml('facturx');
|
||||
|
||||
// Create a new XInvoice from the XML
|
||||
const importedInvoice = await XInvoice.fromXml(xml);
|
||||
// Create a new EInvoice from the XML
|
||||
const importedInvoice = await EInvoice.fromXml(xml);
|
||||
|
||||
// Check that key properties match
|
||||
expect(importedInvoice.id).toEqual(originalInvoice.id);
|
||||
@ -143,21 +143,21 @@ tap.test('XInvoice should maintain data integrity through export/import cycle',
|
||||
});
|
||||
|
||||
// Test validation
|
||||
tap.test('XInvoice should validate XML correctly', async () => {
|
||||
const xinvoice = new XInvoice();
|
||||
xinvoice.id = 'TEST-VALIDATION';
|
||||
xinvoice.invoiceId = 'TEST-VALIDATION';
|
||||
xinvoice.from.name = 'Validation Seller';
|
||||
xinvoice.to.name = 'Validation Buyer';
|
||||
tap.test('EInvoice should validate XML correctly', async () => {
|
||||
const einvoice = new EInvoice();
|
||||
einvoice.id = 'TEST-VALIDATION';
|
||||
einvoice.invoiceId = 'TEST-VALIDATION';
|
||||
einvoice.from.name = 'Validation Seller';
|
||||
einvoice.to.name = 'Validation Buyer';
|
||||
|
||||
// Export as Factur-X
|
||||
const xml = await xinvoice.exportXml('facturx');
|
||||
const xml = await einvoice.exportXml('facturx');
|
||||
|
||||
// Set the XML string for validation
|
||||
xinvoice['xmlString'] = xml;
|
||||
einvoice['xmlString'] = xml;
|
||||
|
||||
// Validate the XML
|
||||
const result = await xinvoice.validate(ValidationLevel.SYNTAX);
|
||||
const result = await einvoice.validate(ValidationLevel.SYNTAX);
|
||||
|
||||
// Check that validation passed
|
||||
expect(result.valid).toBeTrue();
|
394
test/test.error-handling.ts
Normal file
394
test/test.error-handling.ts
Normal file
@ -0,0 +1,394 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import {
|
||||
EInvoice,
|
||||
EInvoiceError,
|
||||
EInvoiceParsingError,
|
||||
EInvoiceValidationError,
|
||||
EInvoicePDFError,
|
||||
EInvoiceFormatError,
|
||||
ErrorRecovery,
|
||||
ErrorContext
|
||||
} from '../ts/index.js';
|
||||
import { ValidationLevel } from '../ts/interfaces/common.js';
|
||||
import { TestFileHelpers, TestFileCategories } from './test-utils.js';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Error handling and recovery test suite
|
||||
*/
|
||||
|
||||
// Test EInvoiceParsingError functionality
|
||||
tap.test('Error Handling - Parsing errors with location info', async () => {
|
||||
const malformedXml = `<?xml version="1.0"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>123</ID>
|
||||
<IssueDate>2024-01-01
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
await EInvoice.fromXml(malformedXml);
|
||||
expect.fail('Should have thrown a parsing error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EInvoiceParsingError);
|
||||
|
||||
if (error instanceof EInvoiceParsingError) {
|
||||
console.log('✓ Parsing error caught correctly');
|
||||
console.log(` Message: ${error.message}`);
|
||||
console.log(` Code: ${error.code}`);
|
||||
console.log(` Detailed: ${error.getDetailedMessage()}`);
|
||||
|
||||
// Check error properties
|
||||
expect(error.code).toEqual('PARSE_ERROR');
|
||||
expect(error.name).toEqual('EInvoiceParsingError');
|
||||
expect(error.details).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test XML recovery mechanisms
|
||||
tap.test('Error Handling - XML recovery for common issues', async () => {
|
||||
// Test 1: XML with BOM
|
||||
const xmlWithBOM = '\ufeff<?xml version="1.0"?><Invoice><ID>123</ID></Invoice>';
|
||||
const bomError = new EInvoiceParsingError('BOM detected', { xmlSnippet: xmlWithBOM.substring(0, 50) });
|
||||
|
||||
const bomRecovery = await ErrorRecovery.attemptXMLRecovery(xmlWithBOM, bomError);
|
||||
expect(bomRecovery.success).toBeTruthy();
|
||||
expect(bomRecovery.cleanedXml).toBeTruthy();
|
||||
expect(bomRecovery.cleanedXml!.charCodeAt(0)).not.toEqual(0xFEFF);
|
||||
console.log('✓ BOM removal recovery successful');
|
||||
|
||||
// Test 2: Unescaped ampersands
|
||||
const xmlWithAmpersand = '<?xml version="1.0"?><Invoice><Name>Smith & Jones Ltd</Name></Invoice>';
|
||||
const ampError = new EInvoiceParsingError('Unescaped ampersand', {});
|
||||
|
||||
const ampRecovery = await ErrorRecovery.attemptXMLRecovery(xmlWithAmpersand, ampError);
|
||||
expect(ampRecovery.success).toBeTruthy();
|
||||
if (ampRecovery.cleanedXml) {
|
||||
expect(ampRecovery.cleanedXml).toInclude('&');
|
||||
console.log('✓ Ampersand escaping recovery successful');
|
||||
}
|
||||
});
|
||||
|
||||
// Test validation error handling
|
||||
tap.test('Error Handling - Validation errors with detailed reports', async () => {
|
||||
const invoice = new EInvoice();
|
||||
|
||||
try {
|
||||
await invoice.validate(ValidationLevel.BUSINESS);
|
||||
expect.fail('Should have thrown validation error for empty invoice');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EInvoiceValidationError);
|
||||
|
||||
if (error instanceof EInvoiceValidationError) {
|
||||
console.log('✓ Validation error caught');
|
||||
console.log('Validation Report:');
|
||||
console.log(error.getValidationReport());
|
||||
|
||||
// Check error filtering
|
||||
const errors = error.getErrorsBySeverity('error');
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
|
||||
const warnings = error.getErrorsBySeverity('warning');
|
||||
console.log(` Errors: ${errors.length}, Warnings: ${warnings.length}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test PDF error handling
|
||||
tap.test('Error Handling - PDF operation errors', async () => {
|
||||
// Test extraction error
|
||||
const extractError = new EInvoicePDFError(
|
||||
'No XML found in PDF',
|
||||
'extract',
|
||||
{
|
||||
pdfInfo: {
|
||||
filename: 'test.pdf',
|
||||
size: 1024 * 1024,
|
||||
pageCount: 10
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('PDF Extraction Error:');
|
||||
console.log(` Message: ${extractError.message}`);
|
||||
console.log(` Operation: ${extractError.operation}`);
|
||||
console.log(' Recovery suggestions:');
|
||||
extractError.getRecoverySuggestions().forEach(s => console.log(` - ${s}`));
|
||||
|
||||
expect(extractError.code).toEqual('PDF_EXTRACT_ERROR');
|
||||
expect(extractError.getRecoverySuggestions().length).toBeGreaterThan(0);
|
||||
|
||||
// Test embed error
|
||||
const embedError = new EInvoicePDFError(
|
||||
'Failed to embed XML',
|
||||
'embed',
|
||||
{ xmlLength: 50000 }
|
||||
);
|
||||
|
||||
expect(embedError.code).toEqual('PDF_EMBED_ERROR');
|
||||
expect(embedError.getRecoverySuggestions()).toContain('Try with a smaller XML payload');
|
||||
});
|
||||
|
||||
// Test format errors
|
||||
tap.test('Error Handling - Format conversion errors', async () => {
|
||||
const formatError = new EInvoiceFormatError(
|
||||
'Cannot convert invoice: incompatible fields',
|
||||
{
|
||||
sourceFormat: 'fatturapa',
|
||||
targetFormat: 'xrechnung',
|
||||
unsupportedFeatures: [
|
||||
'Italian-specific tax codes',
|
||||
'PEC electronic address format',
|
||||
'Bollo virtuale'
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Format Conversion Error:');
|
||||
console.log(formatError.getCompatibilityReport());
|
||||
|
||||
expect(formatError.sourceFormat).toEqual('fatturapa');
|
||||
expect(formatError.targetFormat).toEqual('xrechnung');
|
||||
expect(formatError.unsupportedFeatures?.length).toEqual(3);
|
||||
});
|
||||
|
||||
// Test error context builder
|
||||
tap.test('Error Handling - Error context enrichment', async () => {
|
||||
const context = new ErrorContext()
|
||||
.add('operation', 'invoice_validation')
|
||||
.add('invoiceId', 'INV-2024-001')
|
||||
.add('format', 'facturx')
|
||||
.addTimestamp()
|
||||
.addEnvironment()
|
||||
.build();
|
||||
|
||||
expect(context.operation).toEqual('invoice_validation');
|
||||
expect(context.invoiceId).toEqual('INV-2024-001');
|
||||
expect(context.timestamp).toBeTruthy();
|
||||
expect(context.environment).toBeTruthy();
|
||||
expect(context.environment.nodeVersion).toBeTruthy();
|
||||
|
||||
console.log('✓ Error context built successfully');
|
||||
console.log(` Context keys: ${Object.keys(context).join(', ')}`);
|
||||
});
|
||||
|
||||
// Test error propagation through the stack
|
||||
tap.test('Error Handling - Error propagation and chaining', async () => {
|
||||
// Create a chain of errors
|
||||
const rootCause = new Error('Database connection failed');
|
||||
const serviceError = new EInvoiceError(
|
||||
'Failed to load invoice template',
|
||||
'TEMPLATE_ERROR',
|
||||
{ templateId: 'facturx-v2' },
|
||||
rootCause
|
||||
);
|
||||
const userError = new EInvoicePDFError(
|
||||
'Cannot generate PDF invoice',
|
||||
'create',
|
||||
{ invoiceId: 'INV-001' },
|
||||
serviceError
|
||||
);
|
||||
|
||||
console.log('Error Chain:');
|
||||
console.log(` User sees: ${userError.message}`);
|
||||
console.log(` Caused by: ${userError.cause?.message}`);
|
||||
console.log(` Root cause: ${(userError.cause as EInvoiceError)?.cause?.message}`);
|
||||
|
||||
expect(userError.cause).toBeTruthy();
|
||||
expect((userError.cause as EInvoiceError).cause).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test recovery from real corpus errors
|
||||
tap.test('Error Handling - Recovery from corpus parsing errors', async () => {
|
||||
// Try to load files that might have issues
|
||||
const problematicFiles = [
|
||||
'test/assets/corpus/other/eicar.cii.xml',
|
||||
'test/assets/corpus/other/eicar.ubl.xml'
|
||||
];
|
||||
|
||||
for (const filePath of problematicFiles) {
|
||||
try {
|
||||
const fileBuffer = await TestFileHelpers.loadTestFile(filePath);
|
||||
const xmlString = fileBuffer.toString('utf-8');
|
||||
|
||||
const invoice = await EInvoice.fromXml(xmlString);
|
||||
console.log(`○ ${path.basename(filePath)}: Loaded successfully (no error to handle)`);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof EInvoiceParsingError) {
|
||||
console.log(`✓ ${path.basename(filePath)}: Parsing error handled`);
|
||||
|
||||
// Attempt recovery
|
||||
const recovery = await ErrorRecovery.attemptXMLRecovery(
|
||||
error.details?.xmlString || '',
|
||||
error
|
||||
);
|
||||
|
||||
if (recovery.success) {
|
||||
console.log(` Recovery: ${recovery.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${path.basename(filePath)}: Error handled - ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test concurrent error scenarios
|
||||
tap.test('Error Handling - Concurrent error handling', async () => {
|
||||
const errorScenarios = [
|
||||
async () => {
|
||||
throw new EInvoiceParsingError('Scenario 1: Invalid XML', { line: 10, column: 5 });
|
||||
},
|
||||
async () => {
|
||||
throw new EInvoiceValidationError('Scenario 2: Validation failed', [
|
||||
{ code: 'BR-01', message: 'Invoice number required' }
|
||||
]);
|
||||
},
|
||||
async () => {
|
||||
throw new EInvoicePDFError('Scenario 3: PDF corrupted', 'extract');
|
||||
},
|
||||
async () => {
|
||||
throw new EInvoiceFormatError('Scenario 4: Format unsupported', {
|
||||
sourceFormat: 'custom',
|
||||
targetFormat: 'xrechnung'
|
||||
});
|
||||
}
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(errorScenarios.map(fn => fn()));
|
||||
|
||||
let errorTypeCounts: Record<string, number> = {};
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const errorType = result.reason.constructor.name;
|
||||
errorTypeCounts[errorType] = (errorTypeCounts[errorType] || 0) + 1;
|
||||
console.log(`✓ Scenario ${index + 1}: ${errorType} handled`);
|
||||
}
|
||||
});
|
||||
|
||||
expect(Object.keys(errorTypeCounts).length).toEqual(4);
|
||||
console.log('\nError type distribution:', errorTypeCounts);
|
||||
});
|
||||
|
||||
// Test error serialization for logging
|
||||
tap.test('Error Handling - Error serialization', async () => {
|
||||
const error = new EInvoiceValidationError(
|
||||
'Multiple validation failures',
|
||||
[
|
||||
{ code: 'BR-01', message: 'Invoice number missing', location: '/Invoice/ID' },
|
||||
{ code: 'BR-05', message: 'Invalid date format', location: '/Invoice/IssueDate' },
|
||||
{ code: 'BR-CL-01', message: 'Invalid currency code', location: '/Invoice/DocumentCurrencyCode' }
|
||||
],
|
||||
{ invoiceId: 'TEST-001', validationLevel: 'BUSINESS' }
|
||||
);
|
||||
|
||||
// Test JSON serialization
|
||||
const serialized = JSON.stringify({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
validationErrors: error.validationErrors,
|
||||
details: error.details
|
||||
}, null, 2);
|
||||
|
||||
const parsed = JSON.parse(serialized);
|
||||
expect(parsed.name).toEqual('EInvoiceValidationError');
|
||||
expect(parsed.code).toEqual('VALIDATION_ERROR');
|
||||
expect(parsed.validationErrors.length).toEqual(3);
|
||||
|
||||
console.log('✓ Error serializes correctly for logging');
|
||||
console.log('Serialized error sample:');
|
||||
console.log(serialized.substring(0, 200) + '...');
|
||||
});
|
||||
|
||||
// Test error recovery strategies
|
||||
tap.test('Error Handling - Recovery strategy selection', async () => {
|
||||
// Simulate different error scenarios and recovery strategies
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Missing closing tag',
|
||||
xml: '<?xml version="1.0"?><Invoice><ID>123</ID>',
|
||||
expectedRecovery: false // Hard to recover automatically
|
||||
},
|
||||
{
|
||||
name: 'Extra whitespace',
|
||||
xml: '<?xml version="1.0"?> \n\n <Invoice><ID>123</ID></Invoice>',
|
||||
expectedRecovery: true
|
||||
},
|
||||
{
|
||||
name: 'Wrong encoding declaration',
|
||||
xml: '<?xml version="1.0" encoding="UTF-16"?><Invoice><ID>123</ID></Invoice>',
|
||||
expectedRecovery: true
|
||||
}
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
try {
|
||||
await EInvoice.fromXml(scenario.xml);
|
||||
console.log(`○ ${scenario.name}: No error occurred`);
|
||||
} catch (error) {
|
||||
if (error instanceof EInvoiceParsingError) {
|
||||
const recovery = await ErrorRecovery.attemptXMLRecovery(scenario.xml, error);
|
||||
const result = recovery.success ? '✓' : '✗';
|
||||
console.log(`${result} ${scenario.name}: Recovery ${recovery.success ? 'succeeded' : 'failed'}`);
|
||||
|
||||
if (scenario.expectedRecovery !== recovery.success) {
|
||||
console.log(` Note: Expected recovery=${scenario.expectedRecovery}, got=${recovery.success}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test error metrics collection
|
||||
tap.test('Error Handling - Error metrics and patterns', async () => {
|
||||
const errorMetrics = {
|
||||
total: 0,
|
||||
byType: {} as Record<string, number>,
|
||||
byCode: {} as Record<string, number>,
|
||||
recoveryAttempts: 0,
|
||||
recoverySuccesses: 0
|
||||
};
|
||||
|
||||
// Simulate processing multiple files
|
||||
const testFiles = await TestFileHelpers.getTestFiles(TestFileCategories.EN16931_UBL_INVOICE, 'BR-*.xml');
|
||||
|
||||
for (const file of testFiles.slice(0, 10)) {
|
||||
try {
|
||||
const buffer = await TestFileHelpers.loadTestFile(file);
|
||||
const invoice = await EInvoice.fromXml(buffer.toString('utf-8'));
|
||||
await invoice.validate(ValidationLevel.BUSINESS);
|
||||
} catch (error) {
|
||||
errorMetrics.total++;
|
||||
|
||||
if (error instanceof EInvoiceError) {
|
||||
const type = error.constructor.name;
|
||||
errorMetrics.byType[type] = (errorMetrics.byType[type] || 0) + 1;
|
||||
errorMetrics.byCode[error.code] = (errorMetrics.byCode[error.code] || 0) + 1;
|
||||
|
||||
// Try recovery for parsing errors
|
||||
if (error instanceof EInvoiceParsingError) {
|
||||
errorMetrics.recoveryAttempts++;
|
||||
const recovery = await ErrorRecovery.attemptXMLRecovery('', error);
|
||||
if (recovery.success) {
|
||||
errorMetrics.recoverySuccesses++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nError Metrics Summary:');
|
||||
console.log(` Total errors: ${errorMetrics.total}`);
|
||||
console.log(` Error types:`, errorMetrics.byType);
|
||||
console.log(` Recovery rate: ${errorMetrics.recoveryAttempts > 0
|
||||
? (errorMetrics.recoverySuccesses / errorMetrics.recoveryAttempts * 100).toFixed(1)
|
||||
: 0}%`);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -1,11 +1,11 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test a focused subset of corpus files
|
||||
tap.test('XInvoice should handle a focused subset of corpus files', async () => {
|
||||
tap.test('EInvoice should handle a focused subset of corpus files', async () => {
|
||||
// Get a small subset of files for focused testing
|
||||
const ciiFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII'), '.xml', 5);
|
||||
const ublFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/UBL'), '.xml', 5);
|
||||
@ -55,13 +55,13 @@ async function testXmlFile(file: string, expectedFormat: InvoiceFormat): Promise
|
||||
// Read the file
|
||||
const xmlContent = await fs.readFile(file, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
if (xinvoice && xinvoice.from && xinvoice.to && xinvoice.items) {
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
if (einvoice && einvoice.from && einvoice.to && einvoice.items) {
|
||||
// Check that the format is detected correctly
|
||||
const format = xinvoice.getFormat();
|
||||
const format = einvoice.getFormat();
|
||||
const isCorrectFormat = format === expectedFormat ||
|
||||
(expectedFormat === InvoiceFormat.CII && format === InvoiceFormat.FACTURX) ||
|
||||
(expectedFormat === InvoiceFormat.FACTURX && format === InvoiceFormat.CII) ||
|
||||
@ -76,14 +76,14 @@ async function testXmlFile(file: string, expectedFormat: InvoiceFormat): Promise
|
||||
exportFormat = 'xrechnung';
|
||||
}
|
||||
|
||||
const exportedXml = await xinvoice.exportXml(exportFormat as any);
|
||||
const exportedXml = await einvoice.exportXml(exportFormat as any);
|
||||
|
||||
if (exportedXml) {
|
||||
console.log('✅ Success: File loaded, format detected correctly, and exported successfully');
|
||||
console.log(`Format: ${format}`);
|
||||
console.log(`From: ${xinvoice.from.name}`);
|
||||
console.log(`To: ${xinvoice.to.name}`);
|
||||
console.log(`Items: ${xinvoice.items.length}`);
|
||||
console.log(`From: ${einvoice.from.name}`);
|
||||
console.log(`To: ${einvoice.to.name}`);
|
||||
console.log(`Items: ${einvoice.items.length}`);
|
||||
|
||||
// Save the exported XML for inspection
|
||||
const testDir = path.join(process.cwd(), 'test', 'output', 'focused');
|
||||
@ -176,23 +176,23 @@ async function testPdfFile(file: string): Promise<void> {
|
||||
// Save the extracted XML for inspection
|
||||
await fs.writeFile(path.join(testDir, `${path.basename(file)}-extracted.xml`), xmlContent);
|
||||
|
||||
// Try to create XInvoice from the extracted XML
|
||||
// Try to create EInvoice from the extracted XML
|
||||
try {
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
if (xinvoice && xinvoice.from && xinvoice.to && xinvoice.items) {
|
||||
console.log('✅ Successfully created XInvoice from extracted XML');
|
||||
console.log(`Format: ${xinvoice.getFormat()}`);
|
||||
console.log(`From: ${xinvoice.from.name}`);
|
||||
console.log(`To: ${xinvoice.to.name}`);
|
||||
console.log(`Items: ${xinvoice.items.length}`);
|
||||
if (einvoice && einvoice.from && einvoice.to && einvoice.items) {
|
||||
console.log('✅ Successfully created EInvoice from extracted XML');
|
||||
console.log(`Format: ${einvoice.getFormat()}`);
|
||||
console.log(`From: ${einvoice.from.name}`);
|
||||
console.log(`To: ${einvoice.to.name}`);
|
||||
console.log(`Items: ${einvoice.items.length}`);
|
||||
|
||||
// Try to export the invoice back to XML
|
||||
try {
|
||||
const exportedXml = await xinvoice.exportXml('facturx');
|
||||
const exportedXml = await einvoice.exportXml('facturx');
|
||||
|
||||
if (exportedXml) {
|
||||
console.log('✅ Successfully exported XInvoice back to XML');
|
||||
console.log('✅ Successfully exported EInvoice back to XML');
|
||||
|
||||
// Save the exported XML for inspection
|
||||
await fs.writeFile(path.join(testDir, `${path.basename(file)}-reexported.xml`), exportedXml);
|
||||
@ -203,30 +203,30 @@ async function testPdfFile(file: string): Promise<void> {
|
||||
console.log(`❌ Export error: ${exportError.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Missing required properties in created XInvoice');
|
||||
console.log('❌ Missing required properties in created EInvoice');
|
||||
}
|
||||
} catch (xmlError) {
|
||||
console.log(`❌ Error creating XInvoice from extracted XML: ${xmlError.message}`);
|
||||
console.log(`❌ Error creating EInvoice from extracted XML: ${xmlError.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ No XML found in PDF');
|
||||
}
|
||||
|
||||
// Try to create XInvoice directly from PDF
|
||||
// Try to create EInvoice directly from PDF
|
||||
try {
|
||||
const xinvoice = await XInvoice.fromPdf(pdfBuffer);
|
||||
const einvoice = await EInvoice.fromPdf(pdfBuffer);
|
||||
|
||||
if (xinvoice && xinvoice.from && xinvoice.to && xinvoice.items) {
|
||||
console.log('✅ Successfully created XInvoice directly from PDF');
|
||||
console.log(`Format: ${xinvoice.getFormat()}`);
|
||||
console.log(`From: ${xinvoice.from.name}`);
|
||||
console.log(`To: ${xinvoice.to.name}`);
|
||||
console.log(`Items: ${xinvoice.items.length}`);
|
||||
if (einvoice && einvoice.from && einvoice.to && einvoice.items) {
|
||||
console.log('✅ Successfully created EInvoice directly from PDF');
|
||||
console.log(`Format: ${einvoice.getFormat()}`);
|
||||
console.log(`From: ${einvoice.from.name}`);
|
||||
console.log(`To: ${einvoice.to.name}`);
|
||||
console.log(`Items: ${einvoice.items.length}`);
|
||||
} else {
|
||||
console.log('❌ Missing required properties in created XInvoice');
|
||||
console.log('❌ Missing required properties in created EInvoice');
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.log(`❌ Error creating XInvoice directly from PDF: ${pdfError.message}`);
|
||||
console.log(`❌ Error creating EInvoice directly from PDF: ${pdfError.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Error processing the file: ${error.message}`);
|
||||
|
260
test/test.format-detection.ts
Normal file
260
test/test.format-detection.ts
Normal file
@ -0,0 +1,260 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import { FormatDetector } from '../ts/formats/utils/format.detector.js';
|
||||
import { TestFileHelpers, TestFileCategories, InvoiceAssertions, PerformanceUtils } from './test-utils.js';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Comprehensive format detection tests using the corpus assets
|
||||
*/
|
||||
|
||||
// Test format detection for CII XML-Rechnung files
|
||||
tap.test('Format Detection - CII XML-Rechnung files', async () => {
|
||||
const files = await TestFileHelpers.getTestFiles(TestFileCategories.CII_XMLRECHNUNG, '*.xml');
|
||||
console.log(`Testing ${files.length} CII XML-Rechnung files`);
|
||||
|
||||
for (const file of files) {
|
||||
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
|
||||
const { result: format, duration } = await PerformanceUtils.measure(
|
||||
'cii-detection',
|
||||
async () => FormatDetector.detectFormat(xmlString)
|
||||
);
|
||||
|
||||
// CII files should be detected as either CII or XRechnung
|
||||
const validFormats = [InvoiceFormat.CII, InvoiceFormat.XRECHNUNG];
|
||||
expect(validFormats).toContain(format);
|
||||
|
||||
console.log(`✓ ${path.basename(file)}: ${format} (${duration.toFixed(2)}ms)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test format detection for UBL XML-Rechnung files
|
||||
tap.test('Format Detection - UBL XML-Rechnung files', async () => {
|
||||
const files = await TestFileHelpers.getTestFiles(TestFileCategories.UBL_XMLRECHNUNG, '*.xml');
|
||||
console.log(`Testing ${files.length} UBL XML-Rechnung files`);
|
||||
|
||||
for (const file of files) {
|
||||
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
|
||||
const { result: format, duration } = await PerformanceUtils.measure(
|
||||
'ubl-detection',
|
||||
async () => FormatDetector.detectFormat(xmlString)
|
||||
);
|
||||
|
||||
// UBL files should be detected as either UBL or XRechnung
|
||||
const validFormats = [InvoiceFormat.UBL, InvoiceFormat.XRECHNUNG];
|
||||
expect(validFormats).toContain(format);
|
||||
|
||||
console.log(`✓ ${path.basename(file)}: ${format} (${duration.toFixed(2)}ms)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test format detection for PEPPOL files
|
||||
tap.test('Format Detection - PEPPOL large invoice samples', async () => {
|
||||
const files = await TestFileHelpers.getTestFiles(TestFileCategories.PEPPOL, '*.xml');
|
||||
console.log(`Testing ${files.length} PEPPOL files`);
|
||||
|
||||
for (const file of files) {
|
||||
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
|
||||
const { result: format, duration } = await PerformanceUtils.measure(
|
||||
'peppol-detection',
|
||||
async () => FormatDetector.detectFormat(xmlString)
|
||||
);
|
||||
|
||||
// PEPPOL files are typically UBL format
|
||||
expect(format).toEqual(InvoiceFormat.UBL);
|
||||
|
||||
console.log(`✓ ${path.basename(file)}: ${format} (${duration.toFixed(2)}ms)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test format detection for FatturaPA files
|
||||
tap.test('Format Detection - FatturaPA Italian invoice format', async () => {
|
||||
const files = await TestFileHelpers.getTestFiles(TestFileCategories.FATTURAPA, '*.xml');
|
||||
console.log(`Testing ${files.length} FatturaPA files`);
|
||||
|
||||
let detectedCount = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
|
||||
const { result: format, duration } = await PerformanceUtils.measure(
|
||||
'fatturapa-detection',
|
||||
async () => FormatDetector.detectFormat(xmlString)
|
||||
);
|
||||
|
||||
// FatturaPA detection might not be fully implemented yet
|
||||
if (format === InvoiceFormat.FATTURAPA) {
|
||||
detectedCount++;
|
||||
}
|
||||
|
||||
console.log(`${format === InvoiceFormat.FATTURAPA ? '✓' : '○'} ${path.basename(file)}: ${format} (${duration.toFixed(2)}ms)`);
|
||||
} catch (error) {
|
||||
console.log(`✗ ${path.basename(file)}: Error - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log if FatturaPA detection needs implementation
|
||||
if (detectedCount === 0 && files.length > 0) {
|
||||
console.log('Note: FatturaPA format detection may need implementation');
|
||||
}
|
||||
});
|
||||
|
||||
// Test format detection for EN16931 examples
|
||||
tap.test('Format Detection - EN16931 example files', async () => {
|
||||
// Test CII examples
|
||||
const ciiFiles = await TestFileHelpers.getTestFiles(TestFileCategories.EN16931_EXAMPLES_CII, '*.xml');
|
||||
console.log(`Testing ${ciiFiles.length} EN16931 CII examples`);
|
||||
|
||||
for (const file of ciiFiles) {
|
||||
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
|
||||
const format = FormatDetector.detectFormat(xmlString);
|
||||
expect([InvoiceFormat.CII, InvoiceFormat.FACTURX, InvoiceFormat.XRECHNUNG]).toContain(format);
|
||||
console.log(`✓ ${path.basename(file)}: ${format}`);
|
||||
}
|
||||
|
||||
// Test UBL examples
|
||||
const ublFiles = await TestFileHelpers.getTestFiles(TestFileCategories.EN16931_EXAMPLES_UBL, '*.xml');
|
||||
console.log(`Testing ${ublFiles.length} EN16931 UBL examples`);
|
||||
|
||||
for (const file of ublFiles) {
|
||||
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
|
||||
const format = FormatDetector.detectFormat(xmlString);
|
||||
expect([InvoiceFormat.UBL, InvoiceFormat.XRECHNUNG]).toContain(format);
|
||||
console.log(`✓ ${path.basename(file)}: ${format}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test format detection with malformed/edge case files
|
||||
tap.test('Format Detection - Edge cases and error handling', async () => {
|
||||
// Test empty XML
|
||||
const emptyFormat = FormatDetector.detectFormat('');
|
||||
expect(emptyFormat).toEqual(InvoiceFormat.UNKNOWN);
|
||||
console.log('✓ Empty string returns UNKNOWN');
|
||||
|
||||
// Test non-XML content
|
||||
const textFormat = FormatDetector.detectFormat('This is not XML');
|
||||
expect(textFormat).toEqual(InvoiceFormat.UNKNOWN);
|
||||
console.log('✓ Non-XML text returns UNKNOWN');
|
||||
|
||||
// Test minimal XML
|
||||
const minimalFormat = FormatDetector.detectFormat('<?xml version="1.0"?><root></root>');
|
||||
expect(minimalFormat).toEqual(InvoiceFormat.UNKNOWN);
|
||||
console.log('✓ Minimal XML returns UNKNOWN');
|
||||
|
||||
// Test with BOM
|
||||
const bomXml = '\ufeff<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"></Invoice>';
|
||||
const bomFormat = FormatDetector.detectFormat(bomXml);
|
||||
expect(bomFormat).toEqual(InvoiceFormat.UBL);
|
||||
console.log('✓ XML with BOM is handled correctly');
|
||||
});
|
||||
|
||||
// Test format detection performance
|
||||
tap.test('Format Detection - Performance benchmarks', async () => {
|
||||
console.log('\nPerformance Benchmarks:');
|
||||
|
||||
// Test with small file
|
||||
const smallXml = '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>123</ID></Invoice>';
|
||||
const smallTimes: number[] = [];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now();
|
||||
FormatDetector.detectFormat(smallXml);
|
||||
smallTimes.push(performance.now() - start);
|
||||
}
|
||||
|
||||
const avgSmall = smallTimes.reduce((a, b) => a + b) / smallTimes.length;
|
||||
console.log(`Small XML (${smallXml.length} bytes): avg ${avgSmall.toFixed(3)}ms`);
|
||||
expect(avgSmall).toBeLessThan(1); // Should be very fast
|
||||
|
||||
// Test with large file (if available)
|
||||
try {
|
||||
const largeFiles = await TestFileHelpers.getTestFiles(TestFileCategories.PEPPOL, 'Large*.xml');
|
||||
if (largeFiles.length > 0) {
|
||||
const largeBuffer = await TestFileHelpers.loadTestFile(largeFiles[0]);
|
||||
const largeXml = largeBuffer.toString('utf-8');
|
||||
|
||||
const largeTimes: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const start = performance.now();
|
||||
FormatDetector.detectFormat(largeXml);
|
||||
largeTimes.push(performance.now() - start);
|
||||
}
|
||||
|
||||
const avgLarge = largeTimes.reduce((a, b) => a + b) / largeTimes.length;
|
||||
console.log(`Large XML (${largeXml.length} bytes): avg ${avgLarge.toFixed(3)}ms`);
|
||||
expect(avgLarge).toBeLessThan(10); // Should still be reasonably fast
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Large file test skipped - no large files available');
|
||||
}
|
||||
});
|
||||
|
||||
// Test format detection from PDF embedded XML
|
||||
tap.test('Format Detection - ZUGFeRD PDFs with embedded XML', async () => {
|
||||
const pdfFiles = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V2_CORRECT, '*.pdf');
|
||||
console.log(`Testing ${pdfFiles.length} ZUGFeRD v2 PDF files`);
|
||||
|
||||
let successCount = 0;
|
||||
for (const file of pdfFiles.slice(0, 5)) { // Test first 5 files for speed
|
||||
try {
|
||||
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const einvoice = await EInvoice.fromPdf(pdfBuffer);
|
||||
|
||||
const format = einvoice.getFormat();
|
||||
expect([InvoiceFormat.ZUGFERD, InvoiceFormat.FACTURX]).toContain(format);
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ ${path.basename(file)}: ${format}`);
|
||||
} catch (error) {
|
||||
console.log(`○ ${path.basename(file)}: PDF extraction not available`);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
console.log(`Successfully detected format from ${successCount} PDF files`);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate performance report
|
||||
tap.test('Format Detection - Performance Summary', async () => {
|
||||
const report = PerformanceUtils.generateReport();
|
||||
console.log('\n' + report);
|
||||
|
||||
// Check that detection is generally fast
|
||||
const ciiStats = PerformanceUtils.getStats('cii-detection');
|
||||
if (ciiStats) {
|
||||
expect(ciiStats.avg).toBeLessThan(5); // Average should be under 5ms
|
||||
console.log(`CII detection average: ${ciiStats.avg.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
const ublStats = PerformanceUtils.getStats('ubl-detection');
|
||||
if (ublStats) {
|
||||
expect(ublStats.avg).toBeLessThan(5); // Average should be under 5ms
|
||||
console.log(`UBL detection average: ${ublStats.avg.toFixed(2)}ms`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test the confidence scoring (if implemented)
|
||||
tap.test('Format Detection - Confidence scoring', async () => {
|
||||
// This test is for future implementation when confidence scoring is added
|
||||
console.log('Confidence scoring tests - placeholder for future implementation');
|
||||
|
||||
// Example of what we might test:
|
||||
// const result = FormatDetector.detectFormatWithConfidence(xml);
|
||||
// expect(result.format).toEqual(InvoiceFormat.UBL);
|
||||
// expect(result.confidence).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
tap.start();
|
352
test/test.pdf-operations.ts
Normal file
352
test/test.pdf-operations.ts
Normal file
@ -0,0 +1,352 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { EInvoice, EInvoicePDFError } from '../ts/index.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import { TestFileHelpers, TestFileCategories, PerformanceUtils, TestInvoiceFactory } from './test-utils.js';
|
||||
import * as path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
/**
|
||||
* Comprehensive PDF operations test suite
|
||||
*/
|
||||
|
||||
// Test PDF extraction from ZUGFeRD v1 files
|
||||
tap.test('PDF Operations - Extract XML from ZUGFeRD v1 PDFs', async () => {
|
||||
const pdfFiles = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V1_CORRECT, '*.pdf');
|
||||
console.log(`Testing XML extraction from ${pdfFiles.length} ZUGFeRD v1 PDFs`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const extractionTimes: number[] = [];
|
||||
|
||||
for (const file of pdfFiles.slice(0, 5)) { // Test first 5 for speed
|
||||
const fileName = path.basename(file);
|
||||
|
||||
try {
|
||||
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
|
||||
const { result: einvoice, duration } = await PerformanceUtils.measure(
|
||||
'pdf-extraction-v1',
|
||||
async () => EInvoice.fromPdf(pdfBuffer)
|
||||
);
|
||||
|
||||
extractionTimes.push(duration);
|
||||
|
||||
// Verify extraction succeeded
|
||||
expect(einvoice).toBeTruthy();
|
||||
expect(einvoice.getXml()).toBeTruthy();
|
||||
expect(einvoice.getXml().length).toBeGreaterThan(100);
|
||||
|
||||
// Check format detection
|
||||
const format = einvoice.getFormat();
|
||||
expect([InvoiceFormat.ZUGFERD, InvoiceFormat.FACTURX]).toContain(format);
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ ${fileName}: Extracted ${einvoice.getXml().length} bytes, format: ${format} (${duration.toFixed(2)}ms)`);
|
||||
|
||||
// Verify basic invoice data
|
||||
expect(einvoice.id).toBeTruthy();
|
||||
expect(einvoice.from.name).toBeTruthy();
|
||||
expect(einvoice.to.name).toBeTruthy();
|
||||
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
if (error instanceof EInvoicePDFError) {
|
||||
console.log(`✗ ${fileName}: ${error.message}`);
|
||||
console.log(` Recovery suggestions: ${error.getRecoverySuggestions().join(', ')}`);
|
||||
} else {
|
||||
console.log(`✗ ${fileName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nExtraction Summary: ${successCount} succeeded, ${failCount} failed`);
|
||||
if (extractionTimes.length > 0) {
|
||||
const avgTime = extractionTimes.reduce((a, b) => a + b) / extractionTimes.length;
|
||||
console.log(`Average extraction time: ${avgTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Test PDF extraction from ZUGFeRD v2/Factur-X files
|
||||
tap.test('PDF Operations - Extract XML from ZUGFeRD v2/Factur-X PDFs', async () => {
|
||||
const pdfFiles = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V2_CORRECT, '*.pdf');
|
||||
console.log(`Testing XML extraction from ${pdfFiles.length} ZUGFeRD v2/Factur-X PDFs`);
|
||||
|
||||
const profileStats: Record<string, number> = {};
|
||||
|
||||
for (const file of pdfFiles.slice(0, 10)) { // Test first 10
|
||||
const fileName = path.basename(file);
|
||||
|
||||
try {
|
||||
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const einvoice = await EInvoice.fromPdf(pdfBuffer);
|
||||
|
||||
// Extract profile from filename if present
|
||||
const profileMatch = fileName.match(/(BASIC|COMFORT|EXTENDED|MINIMUM|EN16931)/i);
|
||||
const profile = profileMatch ? profileMatch[1].toUpperCase() : 'UNKNOWN';
|
||||
profileStats[profile] = (profileStats[profile] || 0) + 1;
|
||||
|
||||
console.log(`✓ ${fileName}: Profile ${profile}, Format ${einvoice.getFormat()}`);
|
||||
|
||||
// Test that we can re-export the invoice
|
||||
const xml = await einvoice.exportXml('facturx');
|
||||
expect(xml).toBeTruthy();
|
||||
expect(xml).toInclude('CrossIndustryInvoice');
|
||||
|
||||
} catch (error) {
|
||||
console.log(`✗ ${fileName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nProfile distribution:', profileStats);
|
||||
});
|
||||
|
||||
// Test PDF embedding (creating PDFs with XML)
|
||||
tap.test('PDF Operations - Embed XML into PDF', async () => {
|
||||
// Create a test invoice
|
||||
const invoice = new EInvoice();
|
||||
Object.assign(invoice, TestInvoiceFactory.createComplexInvoice());
|
||||
|
||||
// Generate XML
|
||||
const xml = await invoice.exportXml('facturx');
|
||||
expect(xml).toBeTruthy();
|
||||
console.log(`Generated XML: ${xml.length} bytes`);
|
||||
|
||||
// Create a minimal PDF for testing
|
||||
const pdfBuffer = await createMinimalTestPDF();
|
||||
invoice.pdf = {
|
||||
name: 'test-invoice.pdf',
|
||||
id: 'test-pdf-001',
|
||||
metadata: { textExtraction: '' },
|
||||
buffer: pdfBuffer
|
||||
};
|
||||
|
||||
// Test embedding
|
||||
try {
|
||||
const { result: resultPdf, duration } = await PerformanceUtils.measure(
|
||||
'pdf-embedding',
|
||||
async () => invoice.exportPdf('facturx')
|
||||
);
|
||||
|
||||
expect(resultPdf).toBeTruthy();
|
||||
expect(resultPdf.buffer).toBeTruthy();
|
||||
expect(resultPdf.buffer.length).toBeGreaterThan(pdfBuffer.length);
|
||||
|
||||
console.log(`✓ Successfully embedded XML into PDF (${duration.toFixed(2)}ms)`);
|
||||
console.log(` Original PDF: ${pdfBuffer.length} bytes`);
|
||||
console.log(` Result PDF: ${resultPdf.buffer.length} bytes`);
|
||||
console.log(` Size increase: ${resultPdf.buffer.length - pdfBuffer.length} bytes`);
|
||||
|
||||
// Verify the embedded XML can be extracted
|
||||
const verification = await EInvoice.fromPdf(resultPdf.buffer);
|
||||
expect(verification.getXml()).toBeTruthy();
|
||||
expect(verification.getFormat()).toEqual(InvoiceFormat.FACTURX);
|
||||
console.log('✓ Verified: Embedded XML can be extracted successfully');
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof EInvoicePDFError) {
|
||||
console.log(`✗ Embedding failed: ${error.message}`);
|
||||
console.log(` Operation: ${error.operation}`);
|
||||
console.log(` Suggestions: ${error.getRecoverySuggestions().join(', ')}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Test PDF extraction error handling
|
||||
tap.test('PDF Operations - Error handling for invalid PDFs', async () => {
|
||||
// Test with empty buffer
|
||||
try {
|
||||
await EInvoice.fromPdf(new Uint8Array(0));
|
||||
expect.fail('Should have thrown an error for empty PDF');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EInvoicePDFError);
|
||||
if (error instanceof EInvoicePDFError) {
|
||||
expect(error.operation).toEqual('extract');
|
||||
console.log('✓ Empty PDF error handled correctly');
|
||||
}
|
||||
}
|
||||
|
||||
// Test with non-PDF data
|
||||
try {
|
||||
const textBuffer = Buffer.from('This is not a PDF file');
|
||||
await EInvoice.fromPdf(textBuffer);
|
||||
expect.fail('Should have thrown an error for non-PDF data');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EInvoicePDFError);
|
||||
console.log('✓ Non-PDF data error handled correctly');
|
||||
}
|
||||
|
||||
// Test with corrupted PDF header
|
||||
try {
|
||||
const corruptPdf = Buffer.from('%PDF-1.4\nCorrupted content');
|
||||
await EInvoice.fromPdf(corruptPdf);
|
||||
expect.fail('Should have thrown an error for corrupted PDF');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EInvoicePDFError);
|
||||
console.log('✓ Corrupted PDF error handled correctly');
|
||||
}
|
||||
});
|
||||
|
||||
// Test failed PDF extractions from corpus
|
||||
tap.test('PDF Operations - Handle PDFs without XML gracefully', async () => {
|
||||
const failPdfs = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V1_FAIL, '*.pdf');
|
||||
console.log(`Testing ${failPdfs.length} PDFs expected to fail`);
|
||||
|
||||
for (const file of failPdfs) {
|
||||
const fileName = path.basename(file);
|
||||
|
||||
try {
|
||||
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
await EInvoice.fromPdf(pdfBuffer);
|
||||
console.log(`○ ${fileName}: Unexpectedly succeeded (might have XML)`);
|
||||
} catch (error) {
|
||||
if (error instanceof EInvoicePDFError) {
|
||||
expect(error.operation).toEqual('extract');
|
||||
console.log(`✓ ${fileName}: Correctly failed - ${error.message}`);
|
||||
} else {
|
||||
console.log(`✗ ${fileName}: Wrong error type - ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test PDF metadata preservation
|
||||
tap.test('PDF Operations - Metadata preservation during embedding', async () => {
|
||||
// Load a real PDF from corpus
|
||||
const pdfFiles = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V2_CORRECT, '*.pdf');
|
||||
|
||||
if (pdfFiles.length > 0) {
|
||||
const originalPdfBuffer = await TestFileHelpers.loadTestFile(pdfFiles[0]);
|
||||
|
||||
try {
|
||||
// Extract from original
|
||||
const originalInvoice = await EInvoice.fromPdf(originalPdfBuffer);
|
||||
|
||||
// Re-embed with different format
|
||||
const reembedded = await originalInvoice.exportPdf('xrechnung');
|
||||
|
||||
// Extract again
|
||||
const reextracted = await EInvoice.fromPdf(reembedded.buffer);
|
||||
|
||||
// Compare key fields
|
||||
expect(reextracted.from.name).toEqual(originalInvoice.from.name);
|
||||
expect(reextracted.to.name).toEqual(originalInvoice.to.name);
|
||||
expect(reextracted.items.length).toEqual(originalInvoice.items.length);
|
||||
|
||||
console.log('✓ Metadata preserved through re-embedding cycle');
|
||||
|
||||
} catch (error) {
|
||||
console.log(`○ Metadata preservation test skipped: ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test PDF size constraints
|
||||
tap.test('PDF Operations - Performance with large PDFs', async () => {
|
||||
const largePdfSize = 10 * 1024 * 1024; // 10MB
|
||||
const largePdfBuffer = Buffer.alloc(largePdfSize);
|
||||
|
||||
// Create a simple PDF header
|
||||
const pdfHeader = Buffer.from('%PDF-1.4\n');
|
||||
pdfHeader.copy(largePdfBuffer);
|
||||
|
||||
console.log(`Testing with ${(largePdfSize / 1024 / 1024).toFixed(1)}MB PDF`);
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
await EInvoice.fromPdf(largePdfBuffer);
|
||||
} catch (error) {
|
||||
// Expected to fail, we're testing performance
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✓ Large PDF processed in ${duration.toFixed(2)}ms`);
|
||||
expect(duration).toBeLessThan(5000); // Should fail fast, not hang
|
||||
}
|
||||
});
|
||||
|
||||
// Test concurrent PDF operations
|
||||
tap.test('PDF Operations - Concurrent processing', async () => {
|
||||
const pdfFiles = await TestFileHelpers.getTestFiles(TestFileCategories.ZUGFERD_V2_CORRECT, '*.pdf');
|
||||
const testFiles = pdfFiles.slice(0, 5);
|
||||
|
||||
if (testFiles.length > 0) {
|
||||
console.log(`Testing concurrent processing of ${testFiles.length} PDFs`);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Process all PDFs concurrently
|
||||
const promises = testFiles.map(async (file) => {
|
||||
try {
|
||||
const pdfBuffer = await TestFileHelpers.loadTestFile(file);
|
||||
const einvoice = await EInvoice.fromPdf(pdfBuffer);
|
||||
return { success: true, format: einvoice.getFormat() };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
console.log(`✓ Processed ${successCount}/${testFiles.length} PDFs concurrently in ${duration.toFixed(2)}ms`);
|
||||
console.log(` Average time per PDF: ${(duration / testFiles.length).toFixed(2)}ms`);
|
||||
}
|
||||
});
|
||||
|
||||
// Performance summary
|
||||
tap.test('PDF Operations - Performance Summary', async () => {
|
||||
const stats = {
|
||||
extraction: PerformanceUtils.getStats('pdf-extraction-v1'),
|
||||
embedding: PerformanceUtils.getStats('pdf-embedding')
|
||||
};
|
||||
|
||||
console.log('\nPDF Operations Performance Summary:');
|
||||
|
||||
if (stats.extraction) {
|
||||
console.log('PDF Extraction (ZUGFeRD v1):');
|
||||
console.log(` Average: ${stats.extraction.avg.toFixed(2)}ms`);
|
||||
console.log(` Min/Max: ${stats.extraction.min.toFixed(2)}ms / ${stats.extraction.max.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
if (stats.embedding) {
|
||||
console.log('PDF Embedding:');
|
||||
console.log(` Average: ${stats.embedding.avg.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
// Performance assertions
|
||||
if (stats.extraction && stats.extraction.count > 3) {
|
||||
expect(stats.extraction.avg).toBeLessThan(1000); // Should extract in under 1 second on average
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to create a minimal test PDF
|
||||
async function createMinimalTestPDF(): Promise<Uint8Array> {
|
||||
// This creates a very minimal valid PDF
|
||||
const pdfContent = `%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> >>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer
|
||||
<< /Size 4 /Root 1 0 R >>
|
||||
startxref
|
||||
217
|
||||
%%EOF`;
|
||||
|
||||
return new Uint8Array(Buffer.from(pdfContent));
|
||||
}
|
||||
|
||||
tap.start();
|
@ -1,29 +1,29 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { ValidationLevel, InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test loading and parsing real CII (Factur-X/ZUGFeRD) XML files
|
||||
tap.test('XInvoice should load and parse real CII XML files', async () => {
|
||||
tap.test('EInvoice should load and parse real CII XML files', async () => {
|
||||
// Test with a simple CII file
|
||||
const xmlPath = path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII/EN16931_Einfach.cii.xml');
|
||||
const xmlContent = await fs.readFile(xmlPath, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
expect(xinvoice).toBeTruthy();
|
||||
expect(xinvoice.from).toBeTruthy();
|
||||
expect(xinvoice.to).toBeTruthy();
|
||||
expect(xinvoice.items).toBeArray();
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
expect(einvoice).toBeTruthy();
|
||||
expect(einvoice.from).toBeTruthy();
|
||||
expect(einvoice.to).toBeTruthy();
|
||||
expect(einvoice.items).toBeArray();
|
||||
|
||||
// Check that the format is detected correctly
|
||||
expect(xinvoice.getFormat()).toEqual(InvoiceFormat.FACTURX);
|
||||
expect(einvoice.getFormat()).toEqual(InvoiceFormat.FACTURX);
|
||||
|
||||
// Check that the invoice can be exported back to XML
|
||||
const exportedXml = await xinvoice.exportXml('facturx');
|
||||
const exportedXml = await einvoice.exportXml('facturx');
|
||||
expect(exportedXml).toBeTruthy();
|
||||
expect(exportedXml).toInclude('CrossIndustryInvoice');
|
||||
|
||||
@ -34,47 +34,47 @@ tap.test('XInvoice should load and parse real CII XML files', async () => {
|
||||
});
|
||||
|
||||
// Test loading and parsing real UBL (XRechnung) XML files
|
||||
tap.test('XInvoice should load and parse real UBL XML files', async () => {
|
||||
tap.test('EInvoice should load and parse real UBL XML files', async () => {
|
||||
// Test with a simple UBL file
|
||||
const xmlPath = path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/UBL/EN16931_Einfach.ubl.xml');
|
||||
|
||||
const xmlContent = await fs.readFile(xmlPath, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
expect(xinvoice).toBeTruthy();
|
||||
expect(xinvoice.from).toBeTruthy();
|
||||
expect(xinvoice.to).toBeTruthy();
|
||||
expect(xinvoice.items).toBeArray();
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
expect(einvoice).toBeTruthy();
|
||||
expect(einvoice.from).toBeTruthy();
|
||||
expect(einvoice.to).toBeTruthy();
|
||||
expect(einvoice.items).toBeArray();
|
||||
|
||||
// Check that the format is detected correctly
|
||||
// This file is a UBL format, not XRechnung
|
||||
expect(xinvoice.getFormat()).toEqual(InvoiceFormat.UBL);
|
||||
expect(einvoice.getFormat()).toEqual(InvoiceFormat.UBL);
|
||||
|
||||
// Skip the export test for now since UBL encoder is not implemented yet
|
||||
// This is a legitimate limitation of the current implementation
|
||||
console.log('Skipping UBL export test - UBL encoder not yet implemented');
|
||||
|
||||
// Just test that the format was detected correctly
|
||||
expect(xinvoice.getFormat()).toEqual(InvoiceFormat.UBL);
|
||||
expect(einvoice.getFormat()).toEqual(InvoiceFormat.UBL);
|
||||
});
|
||||
|
||||
// Test PDF creation and extraction with real XML files
|
||||
tap.test('XInvoice should create and parse PDFs with embedded XML', async () => {
|
||||
tap.test('EInvoice should create and parse PDFs with embedded XML', async () => {
|
||||
// Find a real CII XML file to use
|
||||
const xmlPath = path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII/EN16931_Einfach.cii.xml');
|
||||
const xmlContent = await fs.readFile(xmlPath, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
expect(xinvoice).toBeTruthy();
|
||||
expect(xinvoice.from).toBeTruthy();
|
||||
expect(xinvoice.to).toBeTruthy();
|
||||
expect(xinvoice.items).toBeArray();
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
expect(einvoice).toBeTruthy();
|
||||
expect(einvoice.from).toBeTruthy();
|
||||
expect(einvoice.to).toBeTruthy();
|
||||
expect(einvoice.items).toBeArray();
|
||||
|
||||
// Create a simple PDF document
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
@ -84,7 +84,7 @@ tap.test('XInvoice should create and parse PDFs with embedded XML', async () =>
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
|
||||
// Set the PDF buffer
|
||||
xinvoice.pdf = {
|
||||
einvoice.pdf = {
|
||||
name: 'test-invoice.pdf',
|
||||
id: `test-invoice-${Date.now()}`,
|
||||
metadata: {
|
||||
@ -94,7 +94,7 @@ tap.test('XInvoice should create and parse PDFs with embedded XML', async () =>
|
||||
};
|
||||
|
||||
// Export as PDF with embedded XML
|
||||
const exportedPdf = await xinvoice.exportPdf('facturx');
|
||||
const exportedPdf = await einvoice.exportPdf('facturx');
|
||||
expect(exportedPdf).toBeTruthy();
|
||||
expect(exportedPdf.buffer).toBeTruthy();
|
||||
|
||||
@ -104,21 +104,21 @@ tap.test('XInvoice should create and parse PDFs with embedded XML', async () =>
|
||||
await fs.writeFile(path.join(testDir, 'test-invoice-with-xml.pdf'), exportedPdf.buffer);
|
||||
|
||||
// Now try to load the PDF back
|
||||
const loadedXInvoice = await XInvoice.fromPdf(exportedPdf.buffer);
|
||||
const loadedEInvoice = await EInvoice.fromPdf(exportedPdf.buffer);
|
||||
|
||||
// Check that the loaded XInvoice has the expected properties
|
||||
expect(loadedXInvoice).toBeTruthy();
|
||||
expect(loadedXInvoice.from).toBeTruthy();
|
||||
expect(loadedXInvoice.to).toBeTruthy();
|
||||
expect(loadedXInvoice.items).toBeArray();
|
||||
// Check that the loaded EInvoice has the expected properties
|
||||
expect(loadedEInvoice).toBeTruthy();
|
||||
expect(loadedEInvoice.from).toBeTruthy();
|
||||
expect(loadedEInvoice.to).toBeTruthy();
|
||||
expect(loadedEInvoice.items).toBeArray();
|
||||
|
||||
// Check that key properties are present
|
||||
expect(loadedXInvoice.id).toBeTruthy();
|
||||
expect(loadedXInvoice.from.name).toBeTruthy();
|
||||
expect(loadedXInvoice.to.name).toBeTruthy();
|
||||
expect(loadedEInvoice.id).toBeTruthy();
|
||||
expect(loadedEInvoice.from.name).toBeTruthy();
|
||||
expect(loadedEInvoice.to.name).toBeTruthy();
|
||||
|
||||
// Export the loaded invoice back to XML
|
||||
const reExportedXml = await loadedXInvoice.exportXml('facturx');
|
||||
const reExportedXml = await loadedEInvoice.exportXml('facturx');
|
||||
expect(reExportedXml).toBeTruthy();
|
||||
expect(reExportedXml).toInclude('CrossIndustryInvoice');
|
||||
|
||||
@ -153,16 +153,16 @@ async function findPdfFiles(dir: string): Promise<string[]> {
|
||||
};
|
||||
|
||||
// Test validation of real invoice files
|
||||
tap.test('XInvoice should validate real invoice files', async () => {
|
||||
tap.test('EInvoice should validate real invoice files', async () => {
|
||||
// Test with a simple CII file
|
||||
const xmlPath = path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII/EN16931_Einfach.cii.xml');
|
||||
const xmlContent = await fs.readFile(xmlPath, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Validate the XML
|
||||
const result = await xinvoice.validate(ValidationLevel.SYNTAX);
|
||||
const result = await einvoice.validate(ValidationLevel.SYNTAX);
|
||||
|
||||
// Check that validation passed
|
||||
expect(result.valid).toBeTrue();
|
||||
@ -170,7 +170,7 @@ tap.test('XInvoice should validate real invoice files', async () => {
|
||||
});
|
||||
|
||||
// Test with multiple real invoice files
|
||||
tap.test('XInvoice should handle multiple real invoice files', async () => {
|
||||
tap.test('EInvoice should handle multiple real invoice files', async () => {
|
||||
// Get all CII files
|
||||
const ciiDir = path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII');
|
||||
const ciiFiles = await fs.readdir(ciiDir);
|
||||
@ -184,19 +184,19 @@ tap.test('XInvoice should handle multiple real invoice files', async () => {
|
||||
const xmlPath = path.join(ciiDir, file);
|
||||
const xmlContent = await fs.readFile(xmlPath, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
expect(xinvoice).toBeTruthy();
|
||||
expect(xinvoice.from).toBeTruthy();
|
||||
expect(xinvoice.to).toBeTruthy();
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
expect(einvoice).toBeTruthy();
|
||||
expect(einvoice.from).toBeTruthy();
|
||||
expect(einvoice.to).toBeTruthy();
|
||||
|
||||
// Check that the format is detected correctly
|
||||
expect(xinvoice.getFormat()).toEqual(InvoiceFormat.FACTURX);
|
||||
expect(einvoice.getFormat()).toEqual(InvoiceFormat.FACTURX);
|
||||
|
||||
// Check that the invoice can be exported back to XML
|
||||
const exportedXml = await xinvoice.exportXml('facturx');
|
||||
const exportedXml = await einvoice.exportXml('facturx');
|
||||
expect(exportedXml).toBeTruthy();
|
||||
expect(exportedXml).toInclude('CrossIndustryInvoice');
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test a simple subset of corpus files
|
||||
tap.test('XInvoice should handle a simple subset of corpus files', async () => {
|
||||
tap.test('EInvoice should handle a simple subset of corpus files', async () => {
|
||||
// Test a few specific files that we know work
|
||||
const testFiles = [
|
||||
// CII files
|
||||
@ -32,25 +32,25 @@ tap.test('XInvoice should handle a simple subset of corpus files', async () => {
|
||||
// Read the file
|
||||
const xmlContent = await fs.readFile(file, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
if (xinvoice && xinvoice.from && xinvoice.to && xinvoice.items) {
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
if (einvoice && einvoice.from && einvoice.to && einvoice.items) {
|
||||
console.log('✅ Success: File loaded and parsed successfully');
|
||||
console.log(`Format: ${xinvoice.getFormat()}`);
|
||||
console.log(`From: ${xinvoice.from.name}`);
|
||||
console.log(`To: ${xinvoice.to.name}`);
|
||||
console.log(`Items: ${xinvoice.items.length}`);
|
||||
console.log(`Format: ${einvoice.getFormat()}`);
|
||||
console.log(`From: ${einvoice.from.name}`);
|
||||
console.log(`To: ${einvoice.to.name}`);
|
||||
console.log(`Items: ${einvoice.items.length}`);
|
||||
|
||||
// Try to export the invoice back to XML
|
||||
try {
|
||||
let exportFormat = 'facturx';
|
||||
if (xinvoice.getFormat() === InvoiceFormat.UBL || xinvoice.getFormat() === InvoiceFormat.XRECHNUNG) {
|
||||
if (einvoice.getFormat() === InvoiceFormat.UBL || einvoice.getFormat() === InvoiceFormat.XRECHNUNG) {
|
||||
exportFormat = 'xrechnung';
|
||||
}
|
||||
|
||||
const exportedXml = await xinvoice.exportXml(exportFormat as any);
|
||||
const exportedXml = await einvoice.exportXml(exportFormat as any);
|
||||
|
||||
if (exportedXml) {
|
||||
console.log('✅ Successfully exported back to XML');
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat, ValidationLevel } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
tap.test('XInvoice should validate corpus files correctly', async () => {
|
||||
tap.test('EInvoice should validate corpus files correctly', async () => {
|
||||
// Find test files
|
||||
const testDir = path.join(process.cwd(), 'test', 'assets');
|
||||
|
||||
@ -81,21 +81,21 @@ async function testValidation(files: string[], expectValid: boolean) {
|
||||
// Load the XML file
|
||||
const xmlContent = await fs.readFile(file, 'utf8');
|
||||
|
||||
// Create an XInvoice instance
|
||||
let xinvoice: XInvoice;
|
||||
// Create an EInvoice instance
|
||||
let einvoice: EInvoice;
|
||||
|
||||
// If the file is a PDF, load it as a PDF
|
||||
if (file.endsWith('.pdf')) {
|
||||
const pdfBuffer = await fs.readFile(file);
|
||||
xinvoice = await XInvoice.fromPdf(pdfBuffer);
|
||||
einvoice = await EInvoice.fromPdf(pdfBuffer);
|
||||
} else {
|
||||
// Otherwise, load it as XML
|
||||
xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
einvoice = await EInvoice.fromXml(xmlContent);
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate the invoice
|
||||
const validationResult = await xinvoice.validate(ValidationLevel.SYNTAX);
|
||||
const validationResult = await einvoice.validate(ValidationLevel.SYNTAX);
|
||||
|
||||
// Check if the validation result matches our expectation
|
||||
if (validationResult.valid === expectValid) {
|
||||
|
389
test/test.validation-suite.ts
Normal file
389
test/test.validation-suite.ts
Normal file
@ -0,0 +1,389 @@
|
||||
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();
|
@ -1,11 +1,11 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat, ValidationLevel } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test XML-Rechnung corpus (CII and UBL)
|
||||
tap.test('XInvoice should handle XML-Rechnung corpus', async () => {
|
||||
tap.test('EInvoice should handle XML-Rechnung corpus', async () => {
|
||||
// Get all XML-Rechnung files
|
||||
const ciiFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/CII'), '.xml');
|
||||
const ublFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/XML-Rechnung/UBL'), '.xml');
|
||||
@ -73,13 +73,13 @@ async function testFiles(files: string[], expectedFormat: InvoiceFormat): Promis
|
||||
// Read the file
|
||||
const xmlContent = await fs.readFile(file, 'utf8');
|
||||
|
||||
// Create XInvoice from XML
|
||||
const xinvoice = await XInvoice.fromXml(xmlContent);
|
||||
// Create EInvoice from XML
|
||||
const einvoice = await EInvoice.fromXml(xmlContent);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
if (xinvoice && xinvoice.from && xinvoice.to && xinvoice.items) {
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
if (einvoice && einvoice.from && einvoice.to && einvoice.items) {
|
||||
// Check that the format is detected correctly
|
||||
const format = xinvoice.getFormat();
|
||||
const format = einvoice.getFormat();
|
||||
const isCorrectFormat = format === expectedFormat ||
|
||||
(expectedFormat === InvoiceFormat.CII && format === InvoiceFormat.FACTURX) ||
|
||||
(expectedFormat === InvoiceFormat.FACTURX && format === InvoiceFormat.CII) ||
|
||||
@ -94,7 +94,7 @@ async function testFiles(files: string[], expectedFormat: InvoiceFormat): Promis
|
||||
exportFormat = 'xrechnung';
|
||||
}
|
||||
|
||||
const exportedXml = await xinvoice.exportXml(exportFormat as any);
|
||||
const exportedXml = await einvoice.exportXml(exportFormat as any);
|
||||
|
||||
if (exportedXml) {
|
||||
// Success
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { XInvoice } from '../ts/classes.xinvoice.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import { InvoiceFormat, ValidationLevel } from '../ts/interfaces/common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test ZUGFeRD v1 and v2 corpus
|
||||
tap.test('XInvoice should handle ZUGFeRD v1 and v2 corpus', async () => {
|
||||
tap.test('EInvoice should handle ZUGFeRD v1 and v2 corpus', async () => {
|
||||
// Get all ZUGFeRD files
|
||||
const zugferdV1CorrectFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/ZUGFeRDv1/correct'), '.pdf');
|
||||
const zugferdV1FailFiles = await findFiles(path.join(process.cwd(), 'test/assets/corpus/ZUGFeRDv1/fail'), '.pdf');
|
||||
@ -80,19 +80,19 @@ async function testFiles(files: string[], expectSuccess: boolean): Promise<{ suc
|
||||
// Read the file
|
||||
const fileBuffer = await fs.readFile(file);
|
||||
|
||||
// Create XInvoice from PDF
|
||||
const xinvoice = await XInvoice.fromPdf(fileBuffer);
|
||||
// Create EInvoice from PDF
|
||||
const einvoice = await EInvoice.fromPdf(fileBuffer);
|
||||
|
||||
// Check that the XInvoice instance has the expected properties
|
||||
if (xinvoice && xinvoice.from && xinvoice.to && xinvoice.items) {
|
||||
// Check that the EInvoice instance has the expected properties
|
||||
if (einvoice && einvoice.from && einvoice.to && einvoice.items) {
|
||||
// Check that the format is detected correctly
|
||||
const format = xinvoice.getFormat();
|
||||
const format = einvoice.getFormat();
|
||||
const isZugferd = [InvoiceFormat.ZUGFERD, InvoiceFormat.FACTURX, InvoiceFormat.CII].includes(format);
|
||||
|
||||
if (isZugferd) {
|
||||
// Try to export the invoice to XML
|
||||
try {
|
||||
const exportedXml = await xinvoice.exportXml('facturx');
|
||||
const exportedXml = await einvoice.exportXml('facturx');
|
||||
|
||||
if (exportedXml && exportedXml.includes('CrossIndustryInvoice')) {
|
||||
// Success
|
||||
|
Reference in New Issue
Block a user