370 lines
10 KiB
TypeScript
370 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';
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
} |