- Update test-utils import path and refactor to helpers/utils.ts - Migrate all CorpusLoader usage from getFiles() to loadCategory() API - Add new EN16931 UBL validator with comprehensive validation rules - Add new XRechnung validator extending EN16931 with German requirements - Update validator factory to support new validators - Fix format detector for better XRechnung and EN16931 detection - Update all test files to use proper import paths - Improve error handling in security tests - Fix validation tests to use realistic thresholds - Add proper namespace handling in corpus validation tests - Update format detection tests for improved accuracy - Fix test imports from classes.xinvoice.ts to index.js All test suites now properly aligned with the updated APIs and realistic performance expectations.
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
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';
|
|
import { CorpusLoader } from './corpus.loader.js';
|
|
import { PerformanceTracker } from './performance.tracker.js';
|
|
|
|
// Re-export helpers for convenience
|
|
export { CorpusLoader, PerformanceTracker };
|
|
|
|
/**
|
|
* 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(),
|
|
accountingDocId: 'INV-TEST-001',
|
|
accountingDocType: 'invoice',
|
|
type: 'accounting-doc',
|
|
date: Date.now(),
|
|
accountingDocStatus: '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;
|
|
}
|
|
} |