test(suite): comprehensive test suite improvements and new validators

- 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.
This commit is contained in:
2025-05-30 18:18:42 +00:00
parent aea5a5ee26
commit 56fd12a6b2
25 changed files with 2122 additions and 502 deletions

View File

@ -4,137 +4,13 @@ import type { ValidationResult } from '../../interfaces/common.js';
import { FormatDetector } from '../utils/format.detector.js';
// Import specific validators
import { UBLBaseValidator } from '../ubl/ubl.validator.js';
import { EN16931UBLValidator } from '../ubl/en16931.ubl.validator.js';
import { XRechnungValidator } from '../ubl/xrechnung.validator.js';
import { FacturXValidator } from '../cii/facturx/facturx.validator.js';
import { ZUGFeRDValidator } from '../cii/zugferd/zugferd.validator.js';
/**
* UBL validator implementation
* Provides validation for standard UBL documents
*/
class UBLValidator extends UBLBaseValidator {
protected validateStructure(): boolean {
// Basic validation to check for required UBL invoice elements
if (!this.doc) return false;
let valid = true;
// Check for required UBL elements
const requiredElements = [
'cbc:ID',
'cbc:IssueDate',
'cac:AccountingSupplierParty',
'cac:AccountingCustomerParty'
];
for (const element of requiredElements) {
if (!this.exists(`//${element}`)) {
this.addError(
'UBL-STRUCT-1',
`Required element ${element} is missing`,
`/${element}`
);
valid = false;
}
}
return valid;
}
protected validateBusinessRules(): boolean {
// Basic business rule validation for UBL
if (!this.doc) return false;
let valid = true;
// Check that issue date is present and valid
const issueDateText = this.getText('//cbc:IssueDate');
if (!issueDateText) {
this.addError(
'UBL-BUS-1',
'Issue date is required',
'//cbc:IssueDate'
);
valid = false;
} else {
const issueDate = new Date(issueDateText);
if (isNaN(issueDate.getTime())) {
this.addError(
'UBL-BUS-2',
'Issue date is not a valid date',
'//cbc:IssueDate'
);
valid = false;
}
}
// Check that at least one invoice line exists
if (!this.exists('//cac:InvoiceLine') && !this.exists('//cac:CreditNoteLine')) {
this.addError(
'UBL-BUS-3',
'At least one invoice line or credit note line is required',
'/'
);
valid = false;
}
return valid;
}
}
/**
* XRechnung validator implementation
* Extends UBL validator with additional XRechnung specific validation rules
*/
class XRechnungValidator extends UBLValidator {
protected validateStructure(): boolean {
// Call the base UBL validation first
const baseValid = super.validateStructure();
let valid = baseValid;
// Check for XRechnung-specific elements
if (!this.exists('//cbc:CustomizationID[contains(text(), "xrechnung")]')) {
this.addError(
'XRECH-STRUCT-1',
'XRechnung customization ID is missing or invalid',
'//cbc:CustomizationID'
);
valid = false;
}
// Check for buyer reference which is mandatory in XRechnung
if (!this.exists('//cbc:BuyerReference')) {
this.addError(
'XRECH-STRUCT-2',
'BuyerReference is required in XRechnung',
'//'
);
valid = false;
}
return valid;
}
protected validateBusinessRules(): boolean {
// Call the base UBL business rule validation
const baseValid = super.validateBusinessRules();
let valid = baseValid;
// German-specific validation rules
// Check for proper VAT ID structure for German VAT IDs
const supplierVatId = this.getText('//cac:AccountingSupplierParty//cbc:CompanyID[../cac:TaxScheme/cbc:ID="VAT"]');
if (supplierVatId && supplierVatId.startsWith('DE') && !/^DE[0-9]{9}$/.test(supplierVatId)) {
this.addError(
'XRECH-BUS-1',
'German VAT ID format is invalid (must be DE followed by 9 digits)',
'//cac:AccountingSupplierParty//cbc:CompanyID'
);
valid = false;
}
return valid;
}
}
// The EN16931UBLValidator handles all UBL-based formats with proper business rules
// No need for legacy validator implementations here
/**
* FatturaPA validator implementation
@ -191,7 +67,7 @@ export class ValidatorFactory {
switch (format) {
case InvoiceFormat.UBL:
return new UBLValidator(xml);
return new EN16931UBLValidator(xml);
case InvoiceFormat.XRECHNUNG:
return new XRechnungValidator(xml);

View File

@ -0,0 +1,216 @@
import { UBLBaseValidator } from './ubl.validator.js';
import { ValidationLevel } from '../../interfaces/common.js';
import { xpath } from '../../plugins.js';
/**
* EN16931-compliant UBL validator that implements all business rules
*/
export class EN16931UBLValidator extends UBLBaseValidator {
/**
* Validates the structure of the UBL document
*/
protected validateStructure(): boolean {
let valid = true;
// Check for required elements
const requiredElements = [
{ path: '//cbc:ID', error: 'Required element cbc:ID is missing' },
{ path: '//cbc:IssueDate', error: 'Required element cbc:IssueDate is missing' },
{ path: '//cbc:CustomizationID', error: 'Required element cbc:CustomizationID is missing' }
];
for (const element of requiredElements) {
if (!this.exists(element.path)) {
this.addError('STRUCT-REQUIRED', element.error, element.path);
valid = false;
}
}
// Check for at least one invoice line or credit note line
const invoiceLines = this.select('//cac:InvoiceLine', this.doc) as Node[];
const creditNoteLines = this.select('//cac:CreditNoteLine', this.doc) as Node[];
if (invoiceLines.length === 0 && creditNoteLines.length === 0) {
this.addError('STRUCT-LINE', 'At least one invoice line or credit note line is required', '/');
valid = false;
}
return valid;
}
/**
* Validates EN16931 business rules
*/
protected validateBusinessRules(): boolean {
let valid = true;
// BR-01: An Invoice shall have a Specification identifier (BT-24).
if (!this.exists('//cbc:CustomizationID')) {
this.addError('BR-01', 'An Invoice shall have a Specification identifier', '//cbc:CustomizationID');
valid = false;
}
// BR-02: An Invoice shall have an Invoice number (BT-1).
if (!this.exists('//cbc:ID')) {
this.addError('BR-02', 'An Invoice shall have an Invoice number', '//cbc:ID');
valid = false;
}
// BR-03: An Invoice shall have an Invoice issue date (BT-2).
if (!this.exists('//cbc:IssueDate')) {
this.addError('BR-03', 'An Invoice shall have an Invoice issue date', '//cbc:IssueDate');
valid = false;
}
// BR-04: An Invoice shall have an Invoice type code (BT-3).
const isInvoice = this.doc.documentElement.localName === 'Invoice';
if (isInvoice && !this.exists('//cbc:InvoiceTypeCode')) {
this.addError('BR-04', 'An Invoice shall have an Invoice type code', '//cbc:InvoiceTypeCode');
valid = false;
}
// BR-05: An Invoice shall have an Invoice currency code (BT-5).
if (!this.exists('//cbc:DocumentCurrencyCode')) {
this.addError('BR-05', 'An Invoice shall have an Invoice currency code', '//cbc:DocumentCurrencyCode');
valid = false;
}
// BR-06: An Invoice shall contain the Seller name (BT-27).
if (!this.exists('//cac:AccountingSupplierParty//cbc:RegistrationName') &&
!this.exists('//cac:AccountingSupplierParty//cbc:Name')) {
this.addError('BR-06', 'An Invoice shall contain the Seller name', '//cac:AccountingSupplierParty');
valid = false;
}
// BR-07: An Invoice shall contain the Buyer name (BT-44).
if (!this.exists('//cac:AccountingCustomerParty//cbc:RegistrationName') &&
!this.exists('//cac:AccountingCustomerParty//cbc:Name')) {
this.addError('BR-07', 'An Invoice shall contain the Buyer name', '//cac:AccountingCustomerParty');
valid = false;
}
// BR-08: An Invoice shall contain the Seller postal address (BG-5).
const sellerAddress = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc)[0];
if (!sellerAddress || !this.exists('.//cbc:IdentificationCode', sellerAddress)) {
this.addError('BR-08', 'An Invoice shall contain the Seller postal address', '//cac:AccountingSupplierParty//cac:PostalAddress');
valid = false;
}
// BR-09: The Seller postal address (BG-5) shall contain a Seller country code (BT-40).
if (sellerAddress && !this.exists('.//cac:Country/cbc:IdentificationCode', sellerAddress)) {
this.addError('BR-09', 'The Seller postal address shall contain a Seller country code', '//cac:AccountingSupplierParty//cac:PostalAddress//cac:Country');
valid = false;
}
// BR-10: An Invoice shall contain the Buyer postal address (BG-8).
const buyerAddress = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc)[0];
if (!buyerAddress || !this.exists('.//cbc:IdentificationCode', buyerAddress)) {
this.addError('BR-10', 'An Invoice shall contain the Buyer postal address', '//cac:AccountingCustomerParty//cac:PostalAddress');
valid = false;
}
// BR-11: The Buyer postal address (BG-8) shall contain a Buyer country code (BT-55).
if (buyerAddress && !this.exists('.//cac:Country/cbc:IdentificationCode', buyerAddress)) {
this.addError('BR-11', 'The Buyer postal address shall contain a Buyer country code', '//cac:AccountingCustomerParty//cac:PostalAddress//cac:Country');
valid = false;
}
// BR-12: An Invoice shall have the Sum of Invoice line net amount (BT-106).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount')) {
this.addError('BR-12', 'An Invoice shall have the Sum of Invoice line net amount', '//cac:LegalMonetaryTotal/cbc:LineExtensionAmount');
valid = false;
}
// BR-13: An Invoice shall have the Invoice total amount without VAT (BT-109).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount')) {
this.addError('BR-13', 'An Invoice shall have the Invoice total amount without VAT', '//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount');
valid = false;
}
// BR-14: An Invoice shall have the Invoice total amount with VAT (BT-112).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount')) {
this.addError('BR-14', 'An Invoice shall have the Invoice total amount with VAT', '//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount');
valid = false;
}
// BR-15: An Invoice shall have the Amount due for payment (BT-115).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:PayableAmount')) {
this.addError('BR-15', 'An Invoice shall have the Amount due for payment', '//cac:LegalMonetaryTotal/cbc:PayableAmount');
valid = false;
}
// BR-16: An Invoice shall have at least one Invoice line (BG-25).
const lines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[];
if (lines.length === 0) {
this.addError('BR-16', 'An Invoice shall have at least one Invoice line', '//cac:InvoiceLine');
valid = false;
}
// Validate calculation rules if we have the necessary data
if (this.exists('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount')) {
valid = this.validateCalculationRules() && valid;
}
return valid;
}
/**
* Validates calculation rules (BR-CO-*)
*/
private validateCalculationRules(): boolean {
let valid = true;
// BR-CO-10: Sum of Invoice line net amount = Σ Invoice line net amount.
const lineExtensionAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount');
const lines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[];
let calculatedSum = 0;
for (const line of lines) {
const lineAmount = this.getNumber('.//cbc:LineExtensionAmount', line);
calculatedSum += lineAmount;
}
// Allow for small rounding differences (0.01)
if (Math.abs(lineExtensionAmount - calculatedSum) > 0.01) {
this.addError(
'BR-CO-10',
`Sum of Invoice line net amount (${lineExtensionAmount}) must equal sum of line amounts (${calculatedSum})`,
'//cac:LegalMonetaryTotal/cbc:LineExtensionAmount'
);
valid = false;
}
// BR-CO-13: Invoice total amount without VAT = Σ Invoice line net amount - Sum of allowances on document level + Sum of charges on document level.
const taxExclusiveAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount');
const allowanceTotal = this.getNumber('//cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount') || 0;
const chargeTotal = this.getNumber('//cac:LegalMonetaryTotal/cbc:ChargeTotalAmount') || 0;
const calculatedTaxExclusive = lineExtensionAmount - allowanceTotal + chargeTotal;
if (Math.abs(taxExclusiveAmount - calculatedTaxExclusive) > 0.01) {
this.addError(
'BR-CO-13',
`Invoice total amount without VAT (${taxExclusiveAmount}) must equal calculated amount (${calculatedTaxExclusive})`,
'//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount'
);
valid = false;
}
// BR-CO-15: Invoice total amount with VAT = Invoice total amount without VAT + Invoice total VAT amount.
const taxInclusiveAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount');
const totalTaxAmount = this.getNumber('//cac:TaxTotal/cbc:TaxAmount') || 0;
const calculatedTaxInclusive = taxExclusiveAmount + totalTaxAmount;
if (Math.abs(taxInclusiveAmount - calculatedTaxInclusive) > 0.01) {
this.addError(
'BR-CO-15',
`Invoice total amount with VAT (${taxInclusiveAmount}) must equal calculated amount (${calculatedTaxInclusive})`,
'//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount'
);
valid = false;
}
return valid;
}
}

View File

@ -0,0 +1,185 @@
import { EN16931UBLValidator } from './en16931.ubl.validator.js';
/**
* XRechnung-specific validator that extends EN16931 validation
* Implements additional German CIUS (Core Invoice Usage Specification) rules
*/
export class XRechnungValidator extends EN16931UBLValidator {
/**
* Validates XRechnung-specific structure requirements
*/
protected validateStructure(): boolean {
// First validate EN16931 structure
let valid = super.validateStructure();
// XRechnung-specific: Check for proper customization ID
const customizationID = this.getText('//cbc:CustomizationID');
if (!customizationID || !customizationID.includes('xrechnung')) {
this.addError(
'XRECH-STRUCT-1',
'XRechnung customization ID is missing or invalid',
'//cbc:CustomizationID'
);
valid = false;
}
return valid;
}
/**
* Validates XRechnung-specific business rules
*/
protected validateBusinessRules(): boolean {
// First validate EN16931 business rules
let valid = super.validateBusinessRules();
// BR-DE-1: Payment terms (BT-20) or Payment due date (BT-9) shall be provided.
if (!this.exists('//cbc:PaymentDueDate') && !this.exists('//cac:PaymentTerms/cbc:Note')) {
this.addError(
'BR-DE-1',
'Payment terms or Payment due date shall be provided',
'//cac:PaymentTerms'
);
valid = false;
}
// BR-DE-2: The element "Buyer reference" (BT-10) shall be provided.
if (!this.exists('//cbc:BuyerReference')) {
this.addError(
'BR-DE-2',
'Buyer reference is required in XRechnung',
'//cbc:BuyerReference'
);
valid = false;
}
// BR-DE-5: In Germany, the element "Seller VAT identifier" (BT-31) shall be provided.
const sellerCountry = this.getText('//cac:AccountingSupplierParty//cac:PostalAddress//cac:Country/cbc:IdentificationCode');
if (sellerCountry === 'DE' && !this.exists('//cac:AccountingSupplierParty//cac:PartyTaxScheme[cac:TaxScheme/cbc:ID="VAT"]/cbc:CompanyID')) {
this.addError(
'BR-DE-5',
'Seller VAT identifier is required for German sellers',
'//cac:AccountingSupplierParty//cac:PartyTaxScheme'
);
valid = false;
}
// BR-DE-6: In Germany, the element "Buyer VAT identifier" (BT-48) shall be provided.
const buyerCountry = this.getText('//cac:AccountingCustomerParty//cac:PostalAddress//cac:Country/cbc:IdentificationCode');
if (buyerCountry === 'DE' && !this.exists('//cac:AccountingCustomerParty//cac:PartyTaxScheme[cac:TaxScheme/cbc:ID="VAT"]/cbc:CompanyID')) {
this.addError(
'BR-DE-6',
'Buyer VAT identifier is required for German buyers',
'//cac:AccountingCustomerParty//cac:PartyTaxScheme'
);
valid = false;
}
// BR-DE-7: The element "Seller city" (BT-37) shall be provided.
if (!this.exists('//cac:AccountingSupplierParty//cac:PostalAddress/cbc:CityName')) {
this.addError(
'BR-DE-7',
'Seller city is required',
'//cac:AccountingSupplierParty//cac:PostalAddress'
);
valid = false;
}
// BR-DE-8: The element "Seller post code" (BT-38) shall be provided.
if (!this.exists('//cac:AccountingSupplierParty//cac:PostalAddress/cbc:PostalZone')) {
this.addError(
'BR-DE-8',
'Seller post code is required',
'//cac:AccountingSupplierParty//cac:PostalAddress'
);
valid = false;
}
// BR-DE-9: The element "Buyer city" (BT-52) shall be provided.
if (!this.exists('//cac:AccountingCustomerParty//cac:PostalAddress/cbc:CityName')) {
this.addError(
'BR-DE-9',
'Buyer city is required',
'//cac:AccountingCustomerParty//cac:PostalAddress'
);
valid = false;
}
// BR-DE-10: The element "Buyer post code" (BT-53) shall be provided.
if (!this.exists('//cac:AccountingCustomerParty//cac:PostalAddress/cbc:PostalZone')) {
this.addError(
'BR-DE-10',
'Buyer post code is required',
'//cac:AccountingCustomerParty//cac:PostalAddress'
);
valid = false;
}
// BR-DE-11: The element "Seller contact telephone number" (BT-42) shall be provided.
if (!this.exists('//cac:AccountingSupplierParty//cac:Contact/cbc:Telephone')) {
this.addError(
'BR-DE-11',
'Seller contact telephone number is required',
'//cac:AccountingSupplierParty//cac:Contact'
);
valid = false;
}
// BR-DE-12: The element "Seller contact email address" (BT-43) shall be provided.
if (!this.exists('//cac:AccountingSupplierParty//cac:Contact/cbc:ElectronicMail')) {
this.addError(
'BR-DE-12',
'Seller contact email address is required',
'//cac:AccountingSupplierParty//cac:Contact'
);
valid = false;
}
// BR-DE-13: The element "Buyer electronic address" (BT-49) shall be provided.
if (!this.exists('//cac:AccountingCustomerParty//cac:Party/cbc:EndpointID')) {
this.addError(
'BR-DE-13',
'Buyer electronic address (EndpointID) is required',
'//cac:AccountingCustomerParty//cac:Party'
);
valid = false;
}
// BR-DE-14: The element "Payment means type code" (BT-81) shall be provided.
if (!this.exists('//cac:PaymentMeans/cbc:PaymentMeansCode')) {
this.addError(
'BR-DE-14',
'Payment means type code is required',
'//cac:PaymentMeans'
);
valid = false;
}
// BR-DE-15: The element "Invoice line identifier" (BT-126) shall be provided.
const invoiceLines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[];
for (let i = 0; i < invoiceLines.length; i++) {
const line = invoiceLines[i];
if (!this.exists('./cbc:ID', line)) {
this.addError(
'BR-DE-15',
`Invoice line ${i + 1} is missing identifier`,
`//cac:InvoiceLine[${i + 1}]`
);
valid = false;
}
}
// German VAT ID format validation
const supplierVatId = this.getText('//cac:AccountingSupplierParty//cbc:CompanyID[../cac:TaxScheme/cbc:ID="VAT"]');
if (supplierVatId && supplierVatId.startsWith('DE') && !/^DE[0-9]{9}$/.test(supplierVatId)) {
this.addError(
'BR-DE-VAT',
'German VAT ID format is invalid (must be DE followed by 9 digits)',
'//cac:AccountingSupplierParty//cbc:CompanyID'
);
valid = false;
}
return valid;
}
}

View File

@ -249,12 +249,16 @@ export class FormatDetector {
for (const idNode of Array.from(idNodes)) {
const profileText = idNode.textContent || '';
// Check for ZUGFeRD profiles
// Check for ZUGFeRD profiles (v1 and v2)
if (
profileText.includes('zugferd') ||
profileText.includes('urn:ferd:') ||
profileText === CII_PROFILE_IDS.ZUGFERD_BASIC ||
profileText === CII_PROFILE_IDS.ZUGFERD_COMFORT ||
profileText === CII_PROFILE_IDS.ZUGFERD_EXTENDED
profileText === CII_PROFILE_IDS.ZUGFERD_EXTENDED ||
profileText === CII_PROFILE_IDS.ZUGFERD_V1_BASIC ||
profileText === CII_PROFILE_IDS.ZUGFERD_V1_COMFORT ||
profileText === CII_PROFILE_IDS.ZUGFERD_V1_EXTENDED
) {
return InvoiceFormat.ZUGFERD;
}