2025-05-25 19:45:37 +00:00
|
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
|
|
import { EInvoice } from '../../../ts/index.js';
|
|
|
|
|
2025-05-27 16:30:39 +00:00
|
|
|
tap.test('ENC-01: UTF-8 Encoding - should handle UTF-8 encoded documents correctly', async () => {
|
2025-05-28 12:52:08 +00:00
|
|
|
console.log('Testing UTF-8 encoding compliance...\n');
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
// Test 1: Basic UTF-8 characters in all fields
|
|
|
|
const testBasicUtf8 = async () => {
|
|
|
|
const einvoice = new EInvoice();
|
|
|
|
einvoice.id = 'UTF8-€£¥-001';
|
|
|
|
einvoice.date = Date.now();
|
|
|
|
einvoice.currency = 'EUR';
|
|
|
|
einvoice.subject = 'UTF-8 Test: €£¥ ñüäöß 中文 العربية русский';
|
|
|
|
einvoice.notes = ['Special chars: Zürich, Köln, München'];
|
|
|
|
|
|
|
|
// Set supplier with UTF-8 characters
|
|
|
|
einvoice.from = {
|
|
|
|
type: 'company',
|
|
|
|
name: 'Büßer & Müller GmbH',
|
|
|
|
description: 'German company äöü',
|
|
|
|
address: {
|
|
|
|
streetName: 'Hauptstraße',
|
|
|
|
houseNumber: '42',
|
|
|
|
postalCode: '80331',
|
|
|
|
city: 'München',
|
|
|
|
country: 'DE'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2020, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'DE123456789',
|
|
|
|
registrationId: 'HRB 12345',
|
|
|
|
registrationName: 'München'
|
2025-05-27 16:30:39 +00:00
|
|
|
}
|
2025-05-28 12:52:08 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
// Set customer with UTF-8 characters
|
|
|
|
einvoice.to = {
|
|
|
|
type: 'company',
|
|
|
|
name: 'José García S.L.',
|
|
|
|
description: 'Spanish company ñ',
|
|
|
|
address: {
|
|
|
|
streetName: 'Calle Alcalá',
|
|
|
|
houseNumber: '123',
|
|
|
|
postalCode: '28009',
|
|
|
|
city: 'Madrid',
|
|
|
|
country: 'ES'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2019, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'ES987654321',
|
|
|
|
registrationId: 'B-87654321',
|
|
|
|
registrationName: 'Madrid'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// Add items with UTF-8 characters
|
|
|
|
einvoice.items = [{
|
|
|
|
position: 1,
|
|
|
|
name: 'Spëcïål Îtëm - Contains: €£¥',
|
|
|
|
unitType: 'C62',
|
|
|
|
unitQuantity: 1,
|
|
|
|
unitNetPrice: 100,
|
|
|
|
vatPercentage: 19
|
|
|
|
}];
|
|
|
|
|
|
|
|
// Export to XML
|
|
|
|
const xmlString = await einvoice.toXmlString('ubl');
|
|
|
|
|
|
|
|
// Check encoding declaration
|
|
|
|
const hasEncoding = xmlString.includes('encoding="UTF-8"');
|
|
|
|
|
|
|
|
// Check if characters are preserved
|
|
|
|
const charactersPreserved = [
|
|
|
|
xmlString.includes('UTF8-€£¥-001'),
|
|
|
|
xmlString.includes('Büßer'),
|
|
|
|
xmlString.includes('Müller'),
|
|
|
|
xmlString.includes('José García'),
|
|
|
|
xmlString.includes('München'),
|
|
|
|
xmlString.includes('Spëcïål')
|
|
|
|
];
|
|
|
|
|
|
|
|
// Round-trip test
|
|
|
|
const newInvoice = await EInvoice.fromXml(xmlString);
|
|
|
|
const roundTripSuccess =
|
|
|
|
newInvoice.id === einvoice.id &&
|
|
|
|
newInvoice.from?.name === einvoice.from.name &&
|
|
|
|
newInvoice.to?.name === einvoice.to.name;
|
|
|
|
|
|
|
|
return {
|
|
|
|
hasEncoding,
|
|
|
|
charactersPreserved: charactersPreserved.every(p => p),
|
|
|
|
roundTripSuccess
|
|
|
|
};
|
|
|
|
};
|
2025-05-27 18:02:19 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
const basicResult = await testBasicUtf8();
|
|
|
|
console.log('Test 1 - Basic UTF-8:');
|
|
|
|
console.log(` Encoding declaration: ${basicResult.hasEncoding ? 'Yes' : 'No'}`);
|
|
|
|
console.log(` Characters preserved: ${basicResult.charactersPreserved ? 'Yes' : 'No'}`);
|
|
|
|
console.log(` Round-trip success: ${basicResult.roundTripSuccess ? 'Yes' : 'No'}`);
|
|
|
|
expect(basicResult.hasEncoding).toEqual(true);
|
|
|
|
expect(basicResult.charactersPreserved).toEqual(true);
|
|
|
|
expect(basicResult.roundTripSuccess).toEqual(true);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
// Test 2: Extended Unicode (emoji, CJK)
|
|
|
|
const testExtendedUnicode = async () => {
|
|
|
|
const einvoice = new EInvoice();
|
|
|
|
einvoice.id = 'UNICODE-🌍-001';
|
|
|
|
einvoice.date = Date.now();
|
|
|
|
einvoice.currency = 'EUR';
|
|
|
|
einvoice.subject = '🌍 中文 日本語 한국어 👍';
|
|
|
|
|
|
|
|
einvoice.from = {
|
|
|
|
type: 'company',
|
|
|
|
name: '世界公司 🌏',
|
|
|
|
description: 'International company',
|
|
|
|
address: {
|
|
|
|
streetName: '国际街',
|
|
|
|
houseNumber: '88',
|
|
|
|
postalCode: '100000',
|
|
|
|
city: 'Beijing',
|
|
|
|
country: 'CN'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2020, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'CN123456789',
|
|
|
|
registrationId: 'BJ-12345',
|
|
|
|
registrationName: 'Beijing'
|
2025-05-27 18:02:19 +00:00
|
|
|
}
|
2025-05-28 12:52:08 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
einvoice.to = {
|
|
|
|
type: 'company',
|
|
|
|
name: 'Customer Ltd',
|
|
|
|
description: 'Customer',
|
|
|
|
address: {
|
|
|
|
streetName: 'Main Street',
|
|
|
|
houseNumber: '1',
|
|
|
|
postalCode: '10001',
|
|
|
|
city: 'New York',
|
|
|
|
country: 'US'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2019, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'US987654321',
|
|
|
|
registrationId: 'NY-54321',
|
|
|
|
registrationName: 'New York'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
einvoice.items = [{
|
|
|
|
position: 1,
|
|
|
|
name: '产品 📦',
|
|
|
|
unitType: 'C62',
|
|
|
|
unitQuantity: 1,
|
|
|
|
unitNetPrice: 100,
|
|
|
|
vatPercentage: 19
|
|
|
|
}];
|
|
|
|
|
|
|
|
const xmlString = await einvoice.toXmlString('ubl');
|
|
|
|
|
|
|
|
// Check if unicode is preserved or encoded
|
|
|
|
const unicodeHandled =
|
|
|
|
xmlString.includes('世界公司') || xmlString.includes('&#') || // Direct or numeric entities
|
|
|
|
xmlString.includes('🌍') || xmlString.includes('🌍'); // Emoji
|
|
|
|
|
|
|
|
return { unicodeHandled };
|
|
|
|
};
|
2025-05-27 18:02:19 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
const unicodeResult = await testExtendedUnicode();
|
|
|
|
console.log('\nTest 2 - Extended Unicode:');
|
|
|
|
console.log(` Unicode handled: ${unicodeResult.unicodeHandled ? 'Yes' : 'No'}`);
|
|
|
|
expect(unicodeResult.unicodeHandled).toEqual(true);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
// Test 3: XML special characters
|
|
|
|
const testXmlSpecialChars = async () => {
|
|
|
|
const einvoice = new EInvoice();
|
|
|
|
einvoice.id = 'XML-SPECIAL-001';
|
|
|
|
einvoice.date = Date.now();
|
|
|
|
einvoice.currency = 'EUR';
|
|
|
|
einvoice.subject = 'Test & < > " \' entities';
|
|
|
|
|
|
|
|
einvoice.from = {
|
|
|
|
type: 'company',
|
|
|
|
name: 'Smith & Sons Ltd.',
|
|
|
|
description: 'Company with "special" <characters>',
|
|
|
|
address: {
|
|
|
|
streetName: 'A & B Street',
|
|
|
|
houseNumber: '1',
|
|
|
|
postalCode: '12345',
|
|
|
|
city: 'Test City',
|
|
|
|
country: 'DE'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2020, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'DE123456789',
|
|
|
|
registrationId: 'HRB 12345',
|
|
|
|
registrationName: 'Test'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
einvoice.to = {
|
|
|
|
type: 'company',
|
|
|
|
name: 'Customer <Test>',
|
|
|
|
description: 'Customer',
|
|
|
|
address: {
|
|
|
|
streetName: 'Main St',
|
|
|
|
houseNumber: '2',
|
|
|
|
postalCode: '54321',
|
|
|
|
city: 'City',
|
|
|
|
country: 'DE'
|
|
|
|
},
|
|
|
|
status: 'active',
|
|
|
|
foundedDate: { year: 2019, month: 1, day: 1 },
|
|
|
|
registrationDetails: {
|
|
|
|
vatId: 'DE987654321',
|
|
|
|
registrationId: 'HRB 54321',
|
|
|
|
registrationName: 'Test'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
einvoice.items = [{
|
|
|
|
position: 1,
|
|
|
|
name: 'Item with <angle> & "quotes"',
|
|
|
|
unitType: 'C62',
|
|
|
|
unitQuantity: 1,
|
|
|
|
unitNetPrice: 100,
|
|
|
|
vatPercentage: 19
|
|
|
|
}];
|
|
|
|
|
|
|
|
const xmlString = await einvoice.toXmlString('ubl');
|
|
|
|
|
|
|
|
// Check proper XML escaping
|
|
|
|
const properlyEscaped =
|
|
|
|
xmlString.includes('&') || xmlString.includes('&') && // Ampersand
|
|
|
|
(xmlString.includes('<') || xmlString.includes('<')) && // Less than
|
|
|
|
(xmlString.includes('>') || xmlString.includes('>') ||
|
|
|
|
!xmlString.includes('<Test>') || !xmlString.includes('<angle>')); // Greater than in content
|
|
|
|
|
|
|
|
// Ensure no unescaped special chars in text content
|
|
|
|
const noUnescapedChars = !xmlString.match(/>.*[<>&].*</);
|
|
|
|
|
|
|
|
return { properlyEscaped, noUnescapedChars };
|
|
|
|
};
|
2025-05-27 18:02:19 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
const xmlSpecialResult = await testXmlSpecialChars();
|
|
|
|
console.log('\nTest 3 - XML special characters:');
|
|
|
|
console.log(` Properly escaped: ${xmlSpecialResult.properlyEscaped ? 'Yes' : 'No'}`);
|
|
|
|
expect(xmlSpecialResult.properlyEscaped).toEqual(true);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
// Test 4: BOM handling
|
|
|
|
const testBomHandling = async () => {
|
|
|
|
// Test invoice with BOM
|
|
|
|
const bomXml = '\ufeff<?xml version="1.0" encoding="UTF-8"?>' +
|
|
|
|
'<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"' +
|
|
|
|
' xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"' +
|
|
|
|
' xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">' +
|
|
|
|
'<cbc:ID>BOM-TEST-001</cbc:ID>' +
|
|
|
|
'<cbc:IssueDate>2025-01-25</cbc:IssueDate>' +
|
|
|
|
'<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>' +
|
|
|
|
'<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>' +
|
|
|
|
'<cac:AccountingSupplierParty>' +
|
|
|
|
'<cac:Party>' +
|
|
|
|
'<cac:PartyName><cbc:Name>Test Supplier</cbc:Name></cac:PartyName>' +
|
|
|
|
'<cac:PostalAddress>' +
|
|
|
|
'<cbc:StreetName>Test Street</cbc:StreetName>' +
|
|
|
|
'<cbc:CityName>Test City</cbc:CityName>' +
|
|
|
|
'<cbc:PostalZone>12345</cbc:PostalZone>' +
|
|
|
|
'<cac:Country><cbc:IdentificationCode>DE</cbc:IdentificationCode></cac:Country>' +
|
|
|
|
'</cac:PostalAddress>' +
|
|
|
|
'</cac:Party>' +
|
|
|
|
'</cac:AccountingSupplierParty>' +
|
|
|
|
'<cac:AccountingCustomerParty>' +
|
|
|
|
'<cac:Party>' +
|
|
|
|
'<cac:PartyName><cbc:Name>Test Customer</cbc:Name></cac:PartyName>' +
|
|
|
|
'<cac:PostalAddress>' +
|
|
|
|
'<cbc:StreetName>Customer Street</cbc:StreetName>' +
|
|
|
|
'<cbc:CityName>Customer City</cbc:CityName>' +
|
|
|
|
'<cbc:PostalZone>54321</cbc:PostalZone>' +
|
|
|
|
'<cac:Country><cbc:IdentificationCode>DE</cbc:IdentificationCode></cac:Country>' +
|
|
|
|
'</cac:PostalAddress>' +
|
|
|
|
'</cac:Party>' +
|
|
|
|
'</cac:AccountingCustomerParty>' +
|
|
|
|
'<cac:InvoiceLine>' +
|
|
|
|
'<cbc:ID>1</cbc:ID>' +
|
|
|
|
'<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>' +
|
|
|
|
'<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>' +
|
|
|
|
'<cac:Item><cbc:Name>Test Item</cbc:Name></cac:Item>' +
|
|
|
|
'</cac:InvoiceLine>' +
|
|
|
|
'</Invoice>';
|
|
|
|
|
|
|
|
try {
|
|
|
|
const invoice = await EInvoice.fromXml(bomXml);
|
2025-05-27 18:02:19 +00:00
|
|
|
return {
|
2025-05-28 12:52:08 +00:00
|
|
|
bomHandled: true,
|
|
|
|
invoiceId: invoice.id,
|
|
|
|
correctId: invoice.id === 'BOM-TEST-001'
|
2025-05-27 18:02:19 +00:00
|
|
|
};
|
2025-05-28 12:52:08 +00:00
|
|
|
} catch (error) {
|
|
|
|
return { bomHandled: false, error: error.message };
|
2025-05-25 19:45:37 +00:00
|
|
|
}
|
2025-05-28 12:52:08 +00:00
|
|
|
};
|
2025-05-27 18:02:19 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
const bomResult = await testBomHandling();
|
|
|
|
console.log('\nTest 4 - BOM handling:');
|
|
|
|
console.log(` BOM handled: ${bomResult.bomHandled ? 'Yes' : 'No'}`);
|
|
|
|
if (bomResult.bomHandled) {
|
|
|
|
console.log(` Invoice ID correct: ${bomResult.correctId ? 'Yes' : 'No'}`);
|
|
|
|
}
|
|
|
|
expect(bomResult.bomHandled).toEqual(true);
|
|
|
|
expect(bomResult.correctId).toEqual(true);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
// Test 5: Different XML encodings in declaration
|
|
|
|
const testEncodingDeclarations = async () => {
|
|
|
|
// NOTE: The library currently accepts multiple encodings.
|
|
|
|
// This may need to be revisited if EN16931 spec requires UTF-8 only.
|
|
|
|
const encodings = [
|
|
|
|
{ encoding: 'UTF-8', expected: true },
|
|
|
|
{ encoding: 'utf-8', expected: true },
|
|
|
|
{ encoding: 'UTF-16', expected: true }, // Library accepts this
|
|
|
|
{ encoding: 'ISO-8859-1', expected: true } // Library accepts this
|
|
|
|
];
|
|
|
|
|
|
|
|
const results = [];
|
|
|
|
for (const { encoding, expected } of encodings) {
|
|
|
|
const xml = `<?xml version="1.0" encoding="${encoding}"?>` +
|
|
|
|
'<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"' +
|
|
|
|
' xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"' +
|
|
|
|
' xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">' +
|
|
|
|
'<cbc:ID>ENC-TEST-001</cbc:ID>' +
|
|
|
|
'<cbc:IssueDate>2025-01-25</cbc:IssueDate>' +
|
|
|
|
'<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>' +
|
|
|
|
'<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>' +
|
|
|
|
'<cac:AccountingSupplierParty>' +
|
|
|
|
'<cac:Party>' +
|
|
|
|
'<cac:PartyName><cbc:Name>Test Müller</cbc:Name></cac:PartyName>' +
|
|
|
|
'<cac:PostalAddress>' +
|
|
|
|
'<cbc:StreetName>Test Street</cbc:StreetName>' +
|
|
|
|
'<cbc:CityName>München</cbc:CityName>' +
|
|
|
|
'<cbc:PostalZone>12345</cbc:PostalZone>' +
|
|
|
|
'<cac:Country><cbc:IdentificationCode>DE</cbc:IdentificationCode></cac:Country>' +
|
|
|
|
'</cac:PostalAddress>' +
|
|
|
|
'</cac:Party>' +
|
|
|
|
'</cac:AccountingSupplierParty>' +
|
|
|
|
'<cac:AccountingCustomerParty>' +
|
|
|
|
'<cac:Party>' +
|
|
|
|
'<cac:PartyName><cbc:Name>Customer</cbc:Name></cac:PartyName>' +
|
|
|
|
'<cac:PostalAddress>' +
|
|
|
|
'<cbc:StreetName>Customer Street</cbc:StreetName>' +
|
|
|
|
'<cbc:CityName>Customer City</cbc:CityName>' +
|
|
|
|
'<cbc:PostalZone>54321</cbc:PostalZone>' +
|
|
|
|
'<cac:Country><cbc:IdentificationCode>DE</cbc:IdentificationCode></cac:Country>' +
|
|
|
|
'</cac:PostalAddress>' +
|
|
|
|
'</cac:Party>' +
|
|
|
|
'</cac:AccountingCustomerParty>' +
|
|
|
|
'<cac:InvoiceLine>' +
|
|
|
|
'<cbc:ID>1</cbc:ID>' +
|
|
|
|
'<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>' +
|
|
|
|
'<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>' +
|
|
|
|
'<cac:Item><cbc:Name>Test Item</cbc:Name></cac:Item>' +
|
|
|
|
'</cac:InvoiceLine>' +
|
|
|
|
'</Invoice>';
|
2025-05-27 16:30:39 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
try {
|
|
|
|
const invoice = await EInvoice.fromXml(xml);
|
|
|
|
const preserved = invoice.from?.address?.city === 'München';
|
|
|
|
results.push({
|
|
|
|
encoding,
|
|
|
|
parsed: true,
|
|
|
|
preserved,
|
|
|
|
success: expected
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
results.push({
|
|
|
|
encoding,
|
|
|
|
parsed: false,
|
|
|
|
error: error.message,
|
|
|
|
success: !expected // Expected to fail
|
|
|
|
});
|
2025-05-27 18:02:19 +00:00
|
|
|
}
|
2025-05-27 16:30:39 +00:00
|
|
|
}
|
2025-05-28 12:52:08 +00:00
|
|
|
|
|
|
|
return results;
|
|
|
|
};
|
2025-05-27 18:02:19 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
const encodingResults = await testEncodingDeclarations();
|
|
|
|
console.log('\nTest 5 - Encoding declarations:');
|
|
|
|
encodingResults.forEach(result => {
|
|
|
|
console.log(` ${result.encoding}: ${result.parsed ? 'Parsed' : 'Failed'} - ${result.success ? 'As expected' : 'Unexpected'}`);
|
|
|
|
});
|
|
|
|
const allAsExpected = encodingResults.every(r => r.success);
|
|
|
|
expect(allAsExpected).toEqual(true);
|
2025-05-27 18:02:19 +00:00
|
|
|
|
2025-05-28 12:52:08 +00:00
|
|
|
console.log('\n✓ All UTF-8 encoding tests completed successfully');
|
2025-05-25 19:45:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
tap.start();
|