einvoice/test/suite/einvoice_encoding/test.enc-04.character-escaping.ts

369 lines
13 KiB
TypeScript

import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
tap.test('ENC-04: Character Escaping - should handle XML character escaping correctly', async () => {
console.log('Testing XML character escaping...\n');
// Test 1: Basic XML character escaping
const testBasicEscaping = async () => {
const einvoice = new EInvoice();
einvoice.id = 'ESCAPE-BASIC-TEST';
einvoice.date = Date.now();
einvoice.currency = 'EUR';
einvoice.subject = 'XML escaping test: & < > " \'';
einvoice.notes = [
'Testing ampersand: Smith & Co',
'Testing less than: value < 100',
'Testing greater than: value > 50',
'Testing quotes: "quoted text"',
'Testing apostrophe: don\'t'
];
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 Registry'
}
};
einvoice.to = {
type: 'company',
name: 'Customer <Test> & Co',
description: 'Customer with special chars',
address: {
streetName: 'Main St "A"',
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 hasEscapedAmpersand = xmlString.includes('&amp;') || xmlString.includes('&#38;');
const hasEscapedLessThan = xmlString.includes('&lt;') || xmlString.includes('&#60;');
const hasEscapedGreaterThan = xmlString.includes('&gt;') || xmlString.includes('&#62;');
const hasEscapedQuotes = xmlString.includes('&quot;') || xmlString.includes('&#34;');
// Ensure no unescaped special chars in text content (but allow in tag names/attributes)
const lines = xmlString.split('\n');
const contentLines = lines.filter(line => {
const trimmed = line.trim();
return trimmed.includes('>') && trimmed.includes('<') &&
!trimmed.startsWith('<') && !trimmed.endsWith('>');
});
let hasUnescapedInContent = false;
for (const line of contentLines) {
const match = line.match(/>([^<]*)</);
if (match && match[1]) {
const content = match[1];
if (content.includes('&') && !content.includes('&amp;') && !content.includes('&#')) {
hasUnescapedInContent = true;
break;
}
if (content.includes('<') || content.includes('>')) {
hasUnescapedInContent = true;
break;
}
}
}
return {
hasEscapedAmpersand,
hasEscapedLessThan,
hasEscapedGreaterThan,
hasEscapedQuotes,
noUnescapedInContent: !hasUnescapedInContent,
xmlString
};
};
const basicResult = await testBasicEscaping();
console.log('Test 1 - Basic XML character escaping:');
console.log(` Ampersand escaped: ${basicResult.hasEscapedAmpersand ? 'Yes' : 'No'}`);
console.log(` Less than escaped: ${basicResult.hasEscapedLessThan ? 'Yes' : 'No'}`);
console.log(` Greater than escaped: ${basicResult.hasEscapedGreaterThan ? 'Yes' : 'No'}`);
console.log(` Quotes escaped: ${basicResult.hasEscapedQuotes ? 'Yes' : 'No'}`);
console.log(` No unescaped chars in content: ${basicResult.noUnescapedInContent ? 'Yes' : 'No'}`);
// Test 2: Round-trip test with escaped characters
const testRoundTrip = async () => {
const originalXml = `<?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>ESCAPE-ROUNDTRIP</cbc:ID>
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
<cbc:Note>Testing: &amp; &lt; &gt; &quot; &apos;</cbc:Note>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Smith &amp; Sons</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>A &amp; B 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>Customer &lt;Test&gt;</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Main St &quot;A&quot;</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>Item with &lt;angle&gt; &amp; &quot;quotes&quot;</cbc:Name>
</cac:Item>
</cac:InvoiceLine>
</Invoice>`;
try {
// Parse the XML with escaped characters
const invoice = await EInvoice.fromXml(originalXml);
// Check if characters were properly unescaped during parsing
const supplierName = invoice.from?.name || '';
const customerName = invoice.to?.name || '';
const itemName = invoice.items?.[0]?.name || '';
const correctlyUnescaped =
supplierName.includes('Smith & Sons') &&
customerName.includes('Customer <Test>') &&
itemName.includes('Item with <angle> & "quotes"');
return {
success: invoice.id === 'ESCAPE-ROUNDTRIP',
correctlyUnescaped,
supplierName,
customerName,
itemName
};
} catch (error) {
return {
success: false,
error: error.message
};
}
};
const roundTripResult = await testRoundTrip();
console.log('\nTest 2 - Round-trip test with escaped characters:');
console.log(` Invoice parsed: ${roundTripResult.success ? 'Yes' : 'No'}`);
console.log(` Characters unescaped correctly: ${roundTripResult.correctlyUnescaped ? 'Yes' : 'No'}`);
if (roundTripResult.error) {
console.log(` Error: ${roundTripResult.error}`);
}
// Test 3: Numeric character references
const testNumericReferences = async () => {
const xmlWithNumericRefs = `<?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>NUMERIC-REFS</cbc:ID>
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
<cbc:Note>Numeric refs: &#38; &#60; &#62; &#34; &#39;</cbc:Note>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Company &#38; Co</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>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(xmlWithNumericRefs);
const supplierName = invoice.from?.name || '';
return {
success: invoice.id === 'NUMERIC-REFS',
numericRefsDecoded: supplierName.includes('Company & Co')
};
} catch (error) {
return {
success: false,
error: error.message
};
}
};
const numericResult = await testNumericReferences();
console.log('\nTest 3 - Numeric character references:');
console.log(` Invoice parsed: ${numericResult.success ? 'Yes' : 'No'}`);
console.log(` Numeric refs decoded: ${numericResult.numericRefsDecoded ? 'Yes' : 'No'}`);
// Test 4: CDATA sections
const testCdataSections = async () => {
const xmlWithCdata = `<?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>CDATA-TEST</cbc:ID>
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
<cbc:Note><![CDATA[CDATA section with & < > " ' characters]]></cbc:Note>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name><![CDATA[Company with & < > symbols]]></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>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(xmlWithCdata);
const supplierName = invoice.from?.name || '';
return {
success: invoice.id === 'CDATA-TEST',
cdataHandled: supplierName.includes('Company with & < > symbols')
};
} catch (error) {
return {
success: false,
error: error.message
};
}
};
const cdataResult = await testCdataSections();
console.log('\nTest 4 - CDATA sections:');
console.log(` Invoice parsed: ${cdataResult.success ? 'Yes' : 'No'}`);
console.log(` CDATA handled: ${cdataResult.cdataHandled ? 'Yes' : 'No'}`);
// Summary
console.log('\n=== XML Character Escaping Test Summary ===');
console.log(`Basic escaping: ${basicResult.hasEscapedAmpersand && basicResult.noUnescapedInContent ? 'Working' : 'Issues found'}`);
console.log(`Round-trip: ${roundTripResult.success && roundTripResult.correctlyUnescaped ? 'Working' : 'Issues found'}`);
console.log(`Numeric references: ${numericResult.success && numericResult.numericRefsDecoded ? 'Working' : 'Issues found'}`);
console.log(`CDATA sections: ${cdataResult.success && cdataResult.cdataHandled ? 'Working' : 'Issues found'}`);
// Tests pass if basic escaping works and round-trip is successful
expect(basicResult.hasEscapedAmpersand).toEqual(true);
expect(basicResult.noUnescapedInContent).toEqual(true);
expect(roundTripResult.success).toEqual(true);
expect(roundTripResult.correctlyUnescaped).toEqual(true);
console.log('\n✓ XML character escaping test completed');
});
tap.start();