695 lines
20 KiB
TypeScript
695 lines
20 KiB
TypeScript
import { tap } from '@git.zone/tstest/tapbundle';
|
|
import * as plugins from '../plugins.js';
|
|
import { EInvoice } from '../../../ts/index.js';
|
|
import { PerformanceTracker } from '../performance.tracker.js';
|
|
|
|
const performanceTracker = new PerformanceTracker('EDGE-10: Time Zone Edge Cases');
|
|
|
|
tap.test('EDGE-10: Time Zone Edge Cases - should handle complex timezone scenarios', async (t) => {
|
|
const einvoice = new EInvoice();
|
|
|
|
// Test 1: Date/time across timezone boundaries
|
|
const timezoneBoundaries = await performanceTracker.measureAsync(
|
|
'timezone-boundary-crossing',
|
|
async () => {
|
|
const boundaryTests = [
|
|
{
|
|
name: 'midnight-utc',
|
|
dateTime: '2024-01-15T00:00:00Z',
|
|
timezone: 'UTC',
|
|
expectedLocal: '2024-01-15T00:00:00'
|
|
},
|
|
{
|
|
name: 'midnight-cross-positive',
|
|
dateTime: '2024-01-15T23:59:59+12:00',
|
|
timezone: 'Pacific/Auckland',
|
|
expectedUTC: '2024-01-15T11:59:59Z'
|
|
},
|
|
{
|
|
name: 'midnight-cross-negative',
|
|
dateTime: '2024-01-15T00:00:00-11:00',
|
|
timezone: 'Pacific/Midway',
|
|
expectedUTC: '2024-01-15T11:00:00Z'
|
|
},
|
|
{
|
|
name: 'date-line-crossing',
|
|
dateTime: '2024-01-15T12:00:00+14:00',
|
|
timezone: 'Pacific/Kiritimati',
|
|
expectedUTC: '2024-01-14T22:00:00Z'
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of boundaryTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>TZ-TEST-001</ID>
|
|
<IssueDate>${test.dateTime}</IssueDate>
|
|
<DueDateTime>${test.dateTime}</DueDateTime>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const dates = await einvoice.normalizeDates(parsed, {
|
|
targetTimezone: test.timezone
|
|
});
|
|
|
|
results.push({
|
|
test: test.name,
|
|
parsed: true,
|
|
originalDateTime: test.dateTime,
|
|
normalizedDate: dates?.IssueDate,
|
|
isDatePreserved: dates?.dateIntegrity || false,
|
|
crossesDateBoundary: dates?.crossesDateLine || false
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
parsed: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
timezoneBoundaries.forEach(result => {
|
|
t.ok(result.parsed, `Timezone boundary ${result.test} should be handled`);
|
|
});
|
|
|
|
// Test 2: DST (Daylight Saving Time) transitions
|
|
const dstTransitions = await performanceTracker.measureAsync(
|
|
'dst-transition-handling',
|
|
async () => {
|
|
const dstTests = [
|
|
{
|
|
name: 'spring-forward-gap',
|
|
dateTime: '2024-03-10T02:30:00',
|
|
timezone: 'America/New_York',
|
|
description: 'Time that does not exist due to DST'
|
|
},
|
|
{
|
|
name: 'fall-back-ambiguous',
|
|
dateTime: '2024-11-03T01:30:00',
|
|
timezone: 'America/New_York',
|
|
description: 'Time that occurs twice due to DST'
|
|
},
|
|
{
|
|
name: 'dst-boundary-exact',
|
|
dateTime: '2024-03-31T02:00:00',
|
|
timezone: 'Europe/London',
|
|
description: 'Exact moment of DST transition'
|
|
},
|
|
{
|
|
name: 'southern-hemisphere-dst',
|
|
dateTime: '2024-10-06T02:00:00',
|
|
timezone: 'Australia/Sydney',
|
|
description: 'Southern hemisphere DST transition'
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of dstTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>DST-${test.name}</ID>
|
|
<IssueDateTime>${test.dateTime}</IssueDateTime>
|
|
<ProcessingTime timezone="${test.timezone}">${test.dateTime}</ProcessingTime>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const dstAnalysis = await einvoice.analyzeDSTIssues(parsed);
|
|
|
|
results.push({
|
|
scenario: test.name,
|
|
handled: true,
|
|
hasAmbiguity: dstAnalysis?.isAmbiguous || false,
|
|
isNonExistent: dstAnalysis?.isNonExistent || false,
|
|
suggestion: dstAnalysis?.suggestion,
|
|
adjustedTime: dstAnalysis?.adjusted
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
scenario: test.name,
|
|
handled: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
dstTransitions.forEach(result => {
|
|
t.ok(result.handled, `DST transition ${result.scenario} should be handled`);
|
|
if (result.hasAmbiguity || result.isNonExistent) {
|
|
t.ok(result.suggestion, 'DST issue should have suggestion');
|
|
}
|
|
});
|
|
|
|
// Test 3: Historic timezone changes
|
|
const historicTimezones = await performanceTracker.measureAsync(
|
|
'historic-timezone-changes',
|
|
async () => {
|
|
const historicTests = [
|
|
{
|
|
name: 'pre-timezone-standardization',
|
|
dateTime: '1850-01-01T12:00:00',
|
|
location: 'Europe/London',
|
|
description: 'Before standard time zones'
|
|
},
|
|
{
|
|
name: 'soviet-time-changes',
|
|
dateTime: '1991-03-31T02:00:00',
|
|
location: 'Europe/Moscow',
|
|
description: 'USSR timezone reorganization'
|
|
},
|
|
{
|
|
name: 'samoa-dateline-change',
|
|
dateTime: '2011-12-30T00:00:00',
|
|
location: 'Pacific/Apia',
|
|
description: 'Samoa skipped December 30, 2011'
|
|
},
|
|
{
|
|
name: 'crimea-timezone-change',
|
|
dateTime: '2014-03-30T02:00:00',
|
|
location: 'Europe/Simferopol',
|
|
description: 'Crimea timezone change'
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of historicTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>HISTORIC-${test.name}</ID>
|
|
<HistoricDate>${test.dateTime}</HistoricDate>
|
|
<Location>${test.location}</Location>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const historicAnalysis = await einvoice.handleHistoricDate(parsed, {
|
|
validateHistoric: true
|
|
});
|
|
|
|
results.push({
|
|
test: test.name,
|
|
processed: true,
|
|
isHistoric: historicAnalysis?.isHistoric || false,
|
|
hasTimezoneChange: historicAnalysis?.timezoneChanged || false,
|
|
warnings: historicAnalysis?.warnings || []
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
processed: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
historicTimezones.forEach(result => {
|
|
t.ok(result.processed, `Historic timezone ${result.test} should be processed`);
|
|
});
|
|
|
|
// Test 4: Fractional timezone offsets
|
|
const fractionalTimezones = await performanceTracker.measureAsync(
|
|
'fractional-timezone-offsets',
|
|
async () => {
|
|
const fractionalTests = [
|
|
{
|
|
name: 'newfoundland-half-hour',
|
|
offset: '-03:30',
|
|
timezone: 'America/St_Johns',
|
|
dateTime: '2024-01-15T12:00:00-03:30'
|
|
},
|
|
{
|
|
name: 'india-half-hour',
|
|
offset: '+05:30',
|
|
timezone: 'Asia/Kolkata',
|
|
dateTime: '2024-01-15T12:00:00+05:30'
|
|
},
|
|
{
|
|
name: 'nepal-quarter-hour',
|
|
offset: '+05:45',
|
|
timezone: 'Asia/Kathmandu',
|
|
dateTime: '2024-01-15T12:00:00+05:45'
|
|
},
|
|
{
|
|
name: 'chatham-islands',
|
|
offset: '+12:45',
|
|
timezone: 'Pacific/Chatham',
|
|
dateTime: '2024-01-15T12:00:00+12:45'
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of fractionalTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>FRAC-${test.name}</ID>
|
|
<IssueDateTime>${test.dateTime}</IssueDateTime>
|
|
<PaymentDueTime>${test.dateTime}</PaymentDueTime>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const normalized = await einvoice.normalizeToUTC(parsed);
|
|
|
|
results.push({
|
|
test: test.name,
|
|
offset: test.offset,
|
|
parsed: true,
|
|
correctlyHandled: normalized?.timezoneHandled || false,
|
|
preservedPrecision: normalized?.precisionMaintained || false
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
offset: test.offset,
|
|
parsed: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
fractionalTimezones.forEach(result => {
|
|
t.ok(result.parsed, `Fractional timezone ${result.test} should be parsed`);
|
|
if (result.parsed) {
|
|
t.ok(result.correctlyHandled, 'Fractional offset should be handled correctly');
|
|
}
|
|
});
|
|
|
|
// Test 5: Missing or ambiguous timezone info
|
|
const ambiguousTimezones = await performanceTracker.measureAsync(
|
|
'ambiguous-timezone-info',
|
|
async () => {
|
|
const ambiguousTests = [
|
|
{
|
|
name: 'no-timezone-info',
|
|
xml: `<Invoice>
|
|
<IssueDate>2024-01-15</IssueDate>
|
|
<IssueTime>14:30:00</IssueTime>
|
|
</Invoice>`
|
|
},
|
|
{
|
|
name: 'conflicting-timezones',
|
|
xml: `<Invoice>
|
|
<IssueDateTime>2024-01-15T14:30:00+02:00</IssueDateTime>
|
|
<Timezone>America/New_York</Timezone>
|
|
</Invoice>`
|
|
},
|
|
{
|
|
name: 'local-time-only',
|
|
xml: `<Invoice>
|
|
<Timestamp>2024-01-15T14:30:00</Timestamp>
|
|
</Invoice>`
|
|
},
|
|
{
|
|
name: 'invalid-offset',
|
|
xml: `<Invoice>
|
|
<DateTime>2024-01-15T14:30:00+25:00</DateTime>
|
|
</Invoice>`
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of ambiguousTests) {
|
|
const fullXml = `<?xml version="1.0" encoding="UTF-8"?>${test.xml}`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(fullXml);
|
|
const timezoneAnalysis = await einvoice.resolveTimezones(parsed, {
|
|
defaultTimezone: 'UTC',
|
|
strict: false
|
|
});
|
|
|
|
results.push({
|
|
test: test.name,
|
|
resolved: true,
|
|
hasAmbiguity: timezoneAnalysis?.ambiguous || false,
|
|
resolution: timezoneAnalysis?.resolution,
|
|
confidence: timezoneAnalysis?.confidence || 0
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
resolved: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
ambiguousTimezones.forEach(result => {
|
|
t.ok(result.resolved || result.error,
|
|
`Ambiguous timezone ${result.test} should be handled`);
|
|
if (result.resolved && result.hasAmbiguity) {
|
|
t.ok(result.confidence < 100, 'Ambiguous timezone should have lower confidence');
|
|
}
|
|
});
|
|
|
|
// Test 6: Leap seconds handling
|
|
const leapSeconds = await performanceTracker.measureAsync(
|
|
'leap-seconds-handling',
|
|
async () => {
|
|
const leapSecondTests = [
|
|
{
|
|
name: 'leap-second-23-59-60',
|
|
dateTime: '2016-12-31T23:59:60Z',
|
|
description: 'Actual leap second'
|
|
},
|
|
{
|
|
name: 'near-leap-second',
|
|
dateTime: '2016-12-31T23:59:59.999Z',
|
|
description: 'Just before leap second'
|
|
},
|
|
{
|
|
name: 'after-leap-second',
|
|
dateTime: '2017-01-01T00:00:00.001Z',
|
|
description: 'Just after leap second'
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of leapSecondTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>LEAP-${test.name}</ID>
|
|
<PreciseTimestamp>${test.dateTime}</PreciseTimestamp>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const timeHandling = await einvoice.handlePreciseTime(parsed);
|
|
|
|
results.push({
|
|
test: test.name,
|
|
handled: true,
|
|
isLeapSecond: timeHandling?.isLeapSecond || false,
|
|
adjusted: timeHandling?.adjusted || false,
|
|
precision: timeHandling?.precision
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
handled: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
leapSeconds.forEach(result => {
|
|
t.ok(result.handled || result.error,
|
|
`Leap second ${result.test} should be processed`);
|
|
});
|
|
|
|
// Test 7: Format-specific timezone handling
|
|
const formatSpecificTimezones = await performanceTracker.measureAsync(
|
|
'format-specific-timezone-handling',
|
|
async () => {
|
|
const formats = [
|
|
{
|
|
format: 'ubl',
|
|
xml: `<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
|
<IssueDate>2024-01-15</IssueDate>
|
|
<IssueTime>14:30:00+02:00</IssueTime>
|
|
</Invoice>`
|
|
},
|
|
{
|
|
format: 'cii',
|
|
xml: `<rsm:CrossIndustryInvoice>
|
|
<rsm:ExchangedDocument>
|
|
<ram:IssueDateTime>
|
|
<udt:DateTimeString format="102">20240115143000</udt:DateTimeString>
|
|
</ram:IssueDateTime>
|
|
</rsm:ExchangedDocument>
|
|
</rsm:CrossIndustryInvoice>`
|
|
},
|
|
{
|
|
format: 'facturx',
|
|
xml: `<Invoice>
|
|
<IssueDateTime>2024-01-15T14:30:00</IssueDateTime>
|
|
<TimeZoneOffset>+0200</TimeZoneOffset>
|
|
</Invoice>`
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of formats) {
|
|
try {
|
|
const parsed = await einvoice.parseDocument(test.xml);
|
|
const standardized = await einvoice.standardizeDateTime(parsed, {
|
|
sourceFormat: test.format
|
|
});
|
|
|
|
results.push({
|
|
format: test.format,
|
|
parsed: true,
|
|
hasDateTime: !!standardized?.dateTime,
|
|
hasTimezone: !!standardized?.timezone,
|
|
normalized: standardized?.normalized || false
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
format: test.format,
|
|
parsed: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
formatSpecificTimezones.forEach(result => {
|
|
t.ok(result.parsed, `Format ${result.format} timezone should be handled`);
|
|
});
|
|
|
|
// Test 8: Business day calculations across timezones
|
|
const businessDayCalculations = await performanceTracker.measureAsync(
|
|
'business-day-calculations',
|
|
async () => {
|
|
const businessTests = [
|
|
{
|
|
name: 'payment-terms-30-days',
|
|
issueDate: '2024-01-15T23:00:00+12:00',
|
|
terms: 30,
|
|
expectedDue: '2024-02-14'
|
|
},
|
|
{
|
|
name: 'cross-month-boundary',
|
|
issueDate: '2024-01-31T22:00:00-05:00',
|
|
terms: 1,
|
|
expectedDue: '2024-02-01'
|
|
},
|
|
{
|
|
name: 'weekend-adjustment',
|
|
issueDate: '2024-01-12T18:00:00Z', // Friday
|
|
terms: 3,
|
|
expectedDue: '2024-01-17' // Skip weekend
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of businessTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>BUSINESS-${test.name}</ID>
|
|
<IssueDateTime>${test.issueDate}</IssueDateTime>
|
|
<PaymentTerms>
|
|
<NetDays>${test.terms}</NetDays>
|
|
</PaymentTerms>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const calculated = await einvoice.calculateDueDate(parsed, {
|
|
skipWeekends: true,
|
|
skipHolidays: true,
|
|
timezone: 'UTC'
|
|
});
|
|
|
|
results.push({
|
|
test: test.name,
|
|
calculated: true,
|
|
dueDate: calculated?.dueDate,
|
|
matchesExpected: calculated?.dueDate === test.expectedDue,
|
|
businessDaysUsed: calculated?.businessDays
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
calculated: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
businessDayCalculations.forEach(result => {
|
|
t.ok(result.calculated, `Business day calculation ${result.test} should work`);
|
|
});
|
|
|
|
// Test 9: Timezone conversion errors
|
|
const timezoneConversionErrors = await performanceTracker.measureAsync(
|
|
'timezone-conversion-errors',
|
|
async () => {
|
|
const errorTests = [
|
|
{
|
|
name: 'invalid-timezone-name',
|
|
timezone: 'Invalid/Timezone',
|
|
dateTime: '2024-01-15T12:00:00'
|
|
},
|
|
{
|
|
name: 'deprecated-timezone',
|
|
timezone: 'US/Eastern', // Deprecated, use America/New_York
|
|
dateTime: '2024-01-15T12:00:00'
|
|
},
|
|
{
|
|
name: 'military-timezone',
|
|
timezone: 'Z', // Zulu time
|
|
dateTime: '2024-01-15T12:00:00'
|
|
},
|
|
{
|
|
name: 'three-letter-timezone',
|
|
timezone: 'EST', // Ambiguous
|
|
dateTime: '2024-01-15T12:00:00'
|
|
}
|
|
];
|
|
|
|
const results = [];
|
|
|
|
for (const test of errorTests) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice>
|
|
<ID>ERROR-${test.name}</ID>
|
|
<DateTime timezone="${test.timezone}">${test.dateTime}</DateTime>
|
|
</Invoice>`;
|
|
|
|
try {
|
|
const parsed = await einvoice.parseXML(xml);
|
|
const converted = await einvoice.convertTimezone(parsed, {
|
|
from: test.timezone,
|
|
to: 'UTC',
|
|
strict: true
|
|
});
|
|
|
|
results.push({
|
|
test: test.name,
|
|
handled: true,
|
|
converted: !!converted,
|
|
fallbackUsed: converted?.fallback || false,
|
|
warning: converted?.warning
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
test: test.name,
|
|
handled: false,
|
|
error: error.message,
|
|
isTimezoneError: error.message.includes('timezone') ||
|
|
error.message.includes('time zone')
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
timezoneConversionErrors.forEach(result => {
|
|
t.ok(result.handled || result.isTimezoneError,
|
|
`Timezone error ${result.test} should be handled appropriately`);
|
|
});
|
|
|
|
// Test 10: Cross-format timezone preservation
|
|
const crossFormatTimezones = await performanceTracker.measureAsync(
|
|
'cross-format-timezone-preservation',
|
|
async () => {
|
|
const testData = {
|
|
dateTime: '2024-01-15T14:30:00+05:30',
|
|
timezone: 'Asia/Kolkata'
|
|
};
|
|
|
|
const sourceUBL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
|
<ID>TZ-PRESERVE-001</ID>
|
|
<IssueDate>2024-01-15</IssueDate>
|
|
<IssueTime>${testData.dateTime}</IssueTime>
|
|
</Invoice>`;
|
|
|
|
const conversions = ['cii', 'xrechnung', 'facturx'];
|
|
const results = [];
|
|
|
|
for (const targetFormat of conversions) {
|
|
try {
|
|
const converted = await einvoice.convertFormat(sourceUBL, targetFormat);
|
|
const reparsed = await einvoice.parseDocument(converted);
|
|
const extractedDateTime = await einvoice.extractDateTime(reparsed);
|
|
|
|
results.push({
|
|
targetFormat,
|
|
converted: true,
|
|
timezonePreserved: extractedDateTime?.timezone === testData.timezone,
|
|
offsetPreserved: extractedDateTime?.offset === '+05:30',
|
|
dateTimeIntact: extractedDateTime?.iso === testData.dateTime
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
targetFormat,
|
|
converted: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
);
|
|
|
|
crossFormatTimezones.forEach(result => {
|
|
t.ok(result.converted, `Conversion to ${result.targetFormat} should succeed`);
|
|
if (result.converted) {
|
|
t.ok(result.timezonePreserved || result.offsetPreserved,
|
|
'Timezone information should be preserved');
|
|
}
|
|
});
|
|
|
|
// Print performance summary
|
|
performanceTracker.printSummary();
|
|
});
|
|
|
|
// Run the test
|
|
tap.start(); |