ti2-tourplan 1.0.138 → 1.0.139
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.test.js +94 -0
- package/itinerary-add-service.js +26 -3
- package/itinerary-search.js +121 -63
- package/itinerary-search.test.js +105 -6
- package/package.json +1 -1
- package/utils.js +12 -0
- package/utils.test.js +16 -0
package/index.test.js
CHANGED
|
@@ -339,6 +339,100 @@ describe('search tests', () => {
|
|
|
339
339
|
expect(request.Remarks).toBe(`Passenger Notes: ${'A'.repeat(43)}`);
|
|
340
340
|
expect(request.Remarks).toHaveLength(60);
|
|
341
341
|
});
|
|
342
|
+
|
|
343
|
+
it.each([
|
|
344
|
+
['an exact boundary reference', { reference: 'A'.repeat(60) }, 'A'.repeat(60)],
|
|
345
|
+
['an overlong reference', { reference: 'B'.repeat(61) }, 'B'.repeat(60)],
|
|
346
|
+
['a long itinerary description', {
|
|
347
|
+
reference: 'A detailed anniversary itinerary with transfers, hotels, touring, and extra arrangements',
|
|
348
|
+
}, 'A detailed anniversary itinerary with transfers, hotels, tou'],
|
|
349
|
+
['the fallback reference', { reference: ' FALLBACK-REFERENCE ' }, 'FALLBACK-REFERENCE'],
|
|
350
|
+
['the preferred agentRef', {
|
|
351
|
+
agentRef: ' AGENT-PICKED ',
|
|
352
|
+
reference: 'REFERENCE-IGNORED',
|
|
353
|
+
}, 'AGENT-PICKED'],
|
|
354
|
+
['a value that expands during sanitization', { agentRef: 'Ä'.repeat(40) }, 'Ae'.repeat(30)],
|
|
355
|
+
['invalid XML characters', { agentRef: ' REF\u0000WITH\u0001CONTROL ' }, 'REFWITHCONTROL'],
|
|
356
|
+
])('normalizes AgentRef from %s without changing the payload', async (
|
|
357
|
+
description,
|
|
358
|
+
sourceFields,
|
|
359
|
+
expected,
|
|
360
|
+
) => {
|
|
361
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
362
|
+
AddServiceReply: { BookingId: '12345', Ref: 'TESTREF', ServiceLineId: '10' },
|
|
363
|
+
}));
|
|
364
|
+
const payload = {
|
|
365
|
+
quoteName: `Agent reference test: ${description}`,
|
|
366
|
+
optionId: 'ABC123',
|
|
367
|
+
startDate: '2026-07-03',
|
|
368
|
+
paxConfigs: [{ roomType: 'Double', adults: 2 }],
|
|
369
|
+
notes: '',
|
|
370
|
+
...sourceFields,
|
|
371
|
+
};
|
|
372
|
+
const originalPayload = { ...payload };
|
|
373
|
+
|
|
374
|
+
await app.addServiceToItinerary({ axios, token, payload });
|
|
375
|
+
|
|
376
|
+
const agentReference = mockCallTourplan.mock.calls[0][0].model.AddServiceRequest.AgentRef;
|
|
377
|
+
expect(agentReference).toBe(expected);
|
|
378
|
+
expect(Array.from(agentReference).length).toBeLessThanOrEqual(60);
|
|
379
|
+
expect(payload).toEqual(originalPayload);
|
|
380
|
+
|
|
381
|
+
const requestXml = js2xmlparser.parse(
|
|
382
|
+
'Request',
|
|
383
|
+
mockCallTourplan.mock.calls[0][0].model,
|
|
384
|
+
hostConnectXmlOptions,
|
|
385
|
+
);
|
|
386
|
+
expect(requestXml.indexOf('<SCUqty>')).toBeLessThan(requestXml.indexOf('<AgentRef>'));
|
|
387
|
+
expect(requestXml.indexOf('<AgentRef>')).toBeLessThan(requestXml.indexOf('<RoomConfigs>'));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('omits AgentRef when the selected source sanitizes to empty', async () => {
|
|
391
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
392
|
+
AddServiceReply: { BookingId: '12345', Ref: 'TESTREF', ServiceLineId: '10' },
|
|
393
|
+
}));
|
|
394
|
+
|
|
395
|
+
await app.addServiceToItinerary({
|
|
396
|
+
axios,
|
|
397
|
+
token,
|
|
398
|
+
payload: {
|
|
399
|
+
quoteName: 'Empty agent reference',
|
|
400
|
+
optionId: 'ABC123',
|
|
401
|
+
startDate: '2026-07-03',
|
|
402
|
+
agentRef: ' \u0001 ',
|
|
403
|
+
reference: 'REFERENCE-IGNORED',
|
|
404
|
+
paxConfigs: [{ roomType: 'Double', adults: 2 }],
|
|
405
|
+
notes: '',
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(mockCallTourplan.mock.calls[0][0].model.AddServiceRequest.AgentRef).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('normalizes a direct-line AgentRef override without changing XML element order', async () => {
|
|
413
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
414
|
+
AddServiceReply: { BookingId: '12345', Ref: 'TESTREF', ServiceLineId: '10' },
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
await app.addServiceToItinerary({
|
|
418
|
+
axios,
|
|
419
|
+
token,
|
|
420
|
+
payload: {
|
|
421
|
+
quoteName: 'Direct override agent reference',
|
|
422
|
+
optionId: 'ABC123',
|
|
423
|
+
startDate: '2026-07-03',
|
|
424
|
+
reference: 'REFERENCE-IGNORED',
|
|
425
|
+
paxConfigs: [{ roomType: 'Double', adults: 2 }],
|
|
426
|
+
notes: '',
|
|
427
|
+
directLinePayload: { AgentRef: 'D'.repeat(61) },
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const model = mockCallTourplan.mock.calls[0][0].model;
|
|
432
|
+
expect(model.AddServiceRequest.AgentRef).toBe('D'.repeat(60));
|
|
433
|
+
const requestXml = js2xmlparser.parse('Request', model, hostConnectXmlOptions);
|
|
434
|
+
expect(requestXml.indexOf('<AgentRef>')).toBeLessThan(requestXml.indexOf('<RoomConfigs>'));
|
|
435
|
+
});
|
|
342
436
|
});
|
|
343
437
|
|
|
344
438
|
describe('cancelBooking', () => {
|
package/itinerary-add-service.js
CHANGED
|
@@ -2,6 +2,7 @@ const R = require('ramda');
|
|
|
2
2
|
const {
|
|
3
3
|
getRoomConfigs,
|
|
4
4
|
escapeInvalidXmlChars,
|
|
5
|
+
normalizeAgentReference,
|
|
5
6
|
CUSTOM_RATE_ID_NAME,
|
|
6
7
|
CUSTOM_NO_RATE_NAME,
|
|
7
8
|
hostConnectXmlOptions,
|
|
@@ -15,6 +16,13 @@ const SERVICE_CANNOT_BE_ADDED_ERROR_MESSAGE = 'Service cannot be added to quote
|
|
|
15
16
|
const getBookingName = quoteName => escapeInvalidXmlChars(quoteName)
|
|
16
17
|
.substring(0, MAX_BOOKING_NAME_LENGTH);
|
|
17
18
|
|
|
19
|
+
const buildAgentReference = ({ agentRef, reference }) => {
|
|
20
|
+
const source = typeof agentRef === 'string' && agentRef.trim() !== ''
|
|
21
|
+
? agentRef
|
|
22
|
+
: reference;
|
|
23
|
+
return normalizeAgentReference(source);
|
|
24
|
+
};
|
|
25
|
+
|
|
18
26
|
const addServiceToItinerary = async ({
|
|
19
27
|
axios,
|
|
20
28
|
token: {
|
|
@@ -30,6 +38,7 @@ const addServiceToItinerary = async ({
|
|
|
30
38
|
quoteId,
|
|
31
39
|
optionId,
|
|
32
40
|
startDate,
|
|
41
|
+
agentRef,
|
|
33
42
|
reference,
|
|
34
43
|
/*
|
|
35
44
|
paxConfigs: [{ roomType: 'DB', adults: 2 }, { roomType: 'TW', children: 2 }]
|
|
@@ -66,6 +75,20 @@ const addServiceToItinerary = async ({
|
|
|
66
75
|
return acc;
|
|
67
76
|
}, {});
|
|
68
77
|
|
|
78
|
+
const directLinePayloadHasAgentReference = directLinePayload &&
|
|
79
|
+
Object.prototype.hasOwnProperty.call(directLinePayload, 'AgentRef');
|
|
80
|
+
const customFieldsHaveAgentReference = Object.prototype.hasOwnProperty.call(
|
|
81
|
+
cfvPerService,
|
|
82
|
+
'AgentRef',
|
|
83
|
+
);
|
|
84
|
+
const payloadAgentReference = buildAgentReference({ agentRef, reference });
|
|
85
|
+
let agentReferenceSource = payloadAgentReference;
|
|
86
|
+
if (directLinePayloadHasAgentReference) agentReferenceSource = directLinePayload.AgentRef;
|
|
87
|
+
if (customFieldsHaveAgentReference) agentReferenceSource = cfvPerService.AgentRef;
|
|
88
|
+
const agentReference = normalizeAgentReference(agentReferenceSource);
|
|
89
|
+
const directLinePayloadWithoutAgentReference = R.omit(['AgentRef'], directLinePayload || {});
|
|
90
|
+
const customFieldsWithoutAgentReference = R.omit(['AgentRef'], cfvPerService);
|
|
91
|
+
|
|
69
92
|
const directHeaderPayloadHasName = directHeaderPayload &&
|
|
70
93
|
Object.prototype.hasOwnProperty.call(directHeaderPayload, 'Name');
|
|
71
94
|
const bookingNameSource = directHeaderPayloadHasName ? directHeaderPayload.Name : quoteName;
|
|
@@ -178,10 +201,10 @@ const addServiceToItinerary = async ({
|
|
|
178
201
|
if (Number.isNaN(num) || num < 1) return 1;
|
|
179
202
|
return num;
|
|
180
203
|
})(),
|
|
181
|
-
AgentRef:
|
|
204
|
+
...(agentReference ? { AgentRef: agentReference } : {}),
|
|
182
205
|
RoomConfigs: getRoomConfigs(paxConfigs),
|
|
183
|
-
...
|
|
184
|
-
...
|
|
206
|
+
...directLinePayloadWithoutAgentReference,
|
|
207
|
+
...customFieldsWithoutAgentReference,
|
|
185
208
|
},
|
|
186
209
|
};
|
|
187
210
|
const replyObj = await callTourplan({
|
package/itinerary-search.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const R = require('ramda');
|
|
2
2
|
const Promise = require('bluebird');
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
escapeInvalidXmlChars,
|
|
5
|
+
normalizeAgentReference,
|
|
6
|
+
hostConnectXmlOptions,
|
|
7
|
+
} = require('./utils');
|
|
4
8
|
const { translateItineraryBooking } = require('./resolvers/itinerary');
|
|
5
9
|
|
|
6
10
|
/** Years to extend the travel window when start or end is missing. */
|
|
@@ -8,6 +12,16 @@ const TRAVEL_WINDOW_SPAN_YEARS = 2;
|
|
|
8
12
|
const LIST_BOOKINGS_CONCURRENCY = 10;
|
|
9
13
|
const MAX_AGENT_REFERENCE_IDS = 20;
|
|
10
14
|
|
|
15
|
+
const buildAgentReferenceSearchVariants = value => {
|
|
16
|
+
const rawReference = value == null ? '' : String(value).trim();
|
|
17
|
+
if (!rawReference) return [];
|
|
18
|
+
const normalizedReference = normalizeAgentReference(rawReference);
|
|
19
|
+
if (!normalizedReference) return [];
|
|
20
|
+
return normalizedReference && normalizedReference !== rawReference
|
|
21
|
+
? [rawReference, normalizedReference]
|
|
22
|
+
: [rawReference];
|
|
23
|
+
};
|
|
24
|
+
|
|
11
25
|
/**
|
|
12
26
|
* Extract the date prefix from a string in the format YYYY-MM-DD.
|
|
13
27
|
* @param {string} value - The string to extract the date prefix from.
|
|
@@ -187,7 +201,7 @@ const searchItineraries = async ({
|
|
|
187
201
|
xmlOptions: hostConnectXmlOptions,
|
|
188
202
|
});
|
|
189
203
|
|
|
190
|
-
let
|
|
204
|
+
let searchCriteriaGroups = [];
|
|
191
205
|
let applyBaseSearchFilters = false;
|
|
192
206
|
let baseSearchFilters = null;
|
|
193
207
|
|
|
@@ -199,37 +213,40 @@ const searchItineraries = async ({
|
|
|
199
213
|
if (hasAgentReferenceIds && rawAgentReferenceIds.length > MAX_AGENT_REFERENCE_IDS) {
|
|
200
214
|
return { bookings: [] };
|
|
201
215
|
}
|
|
202
|
-
const
|
|
216
|
+
const agentReferenceSearchVariants = R.uniqBy(variants => variants[0], rawAgentReferenceIds
|
|
203
217
|
.filter(ref => typeof ref === 'string' || (typeof ref === 'number' && Number.isFinite(ref)))
|
|
204
|
-
.map(
|
|
205
|
-
.filter(
|
|
206
|
-
|
|
207
|
-
if (hasAgentReferenceIds && !uniqueAgentReferenceIds.length) {
|
|
218
|
+
.map(buildAgentReferenceSearchVariants)
|
|
219
|
+
.filter(variants => variants.length > 0));
|
|
220
|
+
if (hasAgentReferenceIds && !agentReferenceSearchVariants.length) {
|
|
208
221
|
return { bookings: [] };
|
|
209
222
|
}
|
|
210
223
|
const normalizedBookingReferenceIds = (
|
|
211
224
|
Array.isArray(bookingReferenceIds) ? bookingReferenceIds : [bookingReferenceIds]
|
|
212
225
|
).filter(v => v != null).map(v => escapeInvalidXmlChars(String(v).trim())).filter(Boolean);
|
|
213
226
|
|
|
214
|
-
if (
|
|
215
|
-
|
|
227
|
+
if (agentReferenceSearchVariants.length) {
|
|
228
|
+
searchCriteriaGroups = agentReferenceSearchVariants
|
|
229
|
+
.map(variants => variants.map(ref => ({ AgentRef: ref })));
|
|
216
230
|
} else if (normalizedBookingReferenceIds.length) {
|
|
217
231
|
// if bookingReferenceIds are provided other search criteria are ignored
|
|
218
|
-
|
|
232
|
+
searchCriteriaGroups = R.uniq(normalizedBookingReferenceIds).map(ref => [{ Ref: ref }]);
|
|
219
233
|
} else if (bookingId) {
|
|
220
234
|
// if bookingId is provided other search criteria are ignored
|
|
221
235
|
// and we search for bookings by bookingId, ref & agentRef
|
|
222
|
-
|
|
236
|
+
const bookingIdText = String(bookingId).trim();
|
|
237
|
+
const normalizedBookingId = escapeInvalidXmlChars(bookingIdText);
|
|
238
|
+
if (!normalizedBookingId) return { bookings: [] };
|
|
239
|
+
searchCriteriaGroups = [
|
|
240
|
+
[{ BookingId: normalizedBookingId }],
|
|
241
|
+
[{ Ref: normalizedBookingId }],
|
|
242
|
+
buildAgentReferenceSearchVariants(bookingIdText).map(ref => ({ AgentRef: ref })),
|
|
243
|
+
];
|
|
223
244
|
} else {
|
|
224
245
|
applyBaseSearchFilters = true;
|
|
225
246
|
if (name) {
|
|
226
|
-
|
|
247
|
+
searchCriteriaGroups.push([{ NameContains: escapeInvalidXmlChars(name) }]);
|
|
227
248
|
}
|
|
228
249
|
}
|
|
229
|
-
// Step2: Remove duplicate criteria so repeated refs don't trigger duplicate upstream calls.
|
|
230
|
-
// R.uniqWith(R.equals) uses deep structural equality, which is safer than
|
|
231
|
-
// R.uniqBy(JSON.stringify) whose output depends on property insertion order.
|
|
232
|
-
searchCriterias = R.uniqWith(R.equals, searchCriterias);
|
|
233
250
|
|
|
234
251
|
// Step3: Build base search filters.
|
|
235
252
|
if (applyBaseSearchFilters) {
|
|
@@ -251,57 +268,96 @@ const searchItineraries = async ({
|
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
// Step4: Fetch for bookings based on the search criterias.
|
|
254
|
-
const allSearches =
|
|
255
|
-
|
|
271
|
+
const allSearches = searchCriteriaGroups.length
|
|
272
|
+
? searchCriteriaGroups
|
|
273
|
+
: [[baseSearchFilters]];
|
|
274
|
+
const listSearchPromises = new Map();
|
|
256
275
|
const settledSearches = await Promise.map(
|
|
257
276
|
allSearches,
|
|
258
|
-
async
|
|
259
|
-
|
|
260
|
-
|
|
277
|
+
async keyObjs => {
|
|
278
|
+
const searchState = await Promise.reduce(keyObjs, async (state, keyObj) => {
|
|
279
|
+
if (state.match) return state;
|
|
261
280
|
try {
|
|
262
|
-
reply
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
281
|
+
let reply;
|
|
282
|
+
try {
|
|
283
|
+
const requestInput = {
|
|
284
|
+
...(baseSearchFilters ? { ...baseSearchFilters } : {}),
|
|
285
|
+
...keyObj,
|
|
286
|
+
};
|
|
287
|
+
const requestKey = JSON.stringify(requestInput);
|
|
288
|
+
if (!listSearchPromises.has(requestKey)) {
|
|
289
|
+
listSearchPromises.set(
|
|
290
|
+
requestKey,
|
|
291
|
+
callTourplan(getPayload('ListBookingsRequest', requestInput)),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
reply = await listSearchPromises.get(requestKey);
|
|
295
|
+
/*
|
|
296
|
+
<Reply>
|
|
297
|
+
<ListBookingsReply>
|
|
298
|
+
<BookingHeaders>
|
|
299
|
+
<BookingHeader>
|
|
300
|
+
<BookingId>320984</BookingId>
|
|
301
|
+
<Ref>ALFI399113</Ref>
|
|
302
|
+
<Name>Barbara Solomon x2 2554776</Name>
|
|
303
|
+
<NameAlias/>
|
|
304
|
+
<QB>B</QB>
|
|
305
|
+
<Consult>TEST AGENT OWNER</Consult>
|
|
306
|
+
<AgentRef>2554776</AgentRef>
|
|
307
|
+
<TravelDate>2025-04-06</TravelDate>
|
|
308
|
+
<EnteredDate>2025-01-30</EnteredDate>
|
|
309
|
+
<BookingStatus>Quotation iCom CNX</BookingStatus>
|
|
310
|
+
<BookingType>F</BookingType>
|
|
311
|
+
<IsInternetBooking>Y</IsInternetBooking>
|
|
312
|
+
<Currency>GBP</Currency>
|
|
313
|
+
<TotalPrice>1016738</TotalPrice>
|
|
314
|
+
</BookingHeader>
|
|
315
|
+
</BookingHeaders>
|
|
316
|
+
</ListBookingsReply>
|
|
317
|
+
</Reply>
|
|
318
|
+
*/
|
|
319
|
+
} catch (err) {
|
|
320
|
+
const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
|
|
321
|
+
if (hasAgentReferenceIds) {
|
|
322
|
+
throw err instanceof Error ? err : Error(errMsg);
|
|
323
|
+
}
|
|
324
|
+
if (errMsg.includes('Request failed with status code')) throw Error(errMsg);
|
|
325
|
+
// if it's not server error, we just considered as no booking is found
|
|
326
|
+
reply = { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
|
|
294
327
|
}
|
|
295
|
-
|
|
296
|
-
|
|
328
|
+
const result = { status: 'fulfilled', value: reply };
|
|
329
|
+
const bookingHeaders = R.pathOr(
|
|
330
|
+
[],
|
|
331
|
+
['ListBookingsReply', 'BookingHeaders', 'BookingHeader'],
|
|
332
|
+
reply,
|
|
333
|
+
);
|
|
334
|
+
if ((Array.isArray(bookingHeaders) && bookingHeaders.length) ||
|
|
335
|
+
(!Array.isArray(bookingHeaders) && bookingHeaders)) {
|
|
336
|
+
return { ...state, match: result };
|
|
297
337
|
}
|
|
298
|
-
|
|
299
|
-
|
|
338
|
+
return { ...state, emptyResult: result };
|
|
339
|
+
} catch (reason) {
|
|
340
|
+
const result = { status: 'rejected', reason };
|
|
341
|
+
if (Object.prototype.hasOwnProperty.call(keyObj, 'AgentRef')) {
|
|
342
|
+
const status = R.path(['response', 'status'], reason)
|
|
343
|
+
|| R.path(['status'], reason)
|
|
344
|
+
|| R.path(['code'], reason)
|
|
345
|
+
|| 'unknown';
|
|
346
|
+
const agentReference = String(keyObj.AgentRef || '');
|
|
347
|
+
const errorMessage = reason instanceof Error ? reason.message : String(reason);
|
|
348
|
+
const redactedErrorMessage = agentReference
|
|
349
|
+
? errorMessage.split(agentReference).join('[redacted]')
|
|
350
|
+
: errorMessage;
|
|
351
|
+
console.warn(
|
|
352
|
+
'[tourplan] AgentRef ListBookingsRequest failed status=%s error=%s',
|
|
353
|
+
status,
|
|
354
|
+
redactedErrorMessage,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return state.firstRejection ? state : { ...state, firstRejection: result };
|
|
300
358
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return { status: 'rejected', reason };
|
|
304
|
-
}
|
|
359
|
+
}, { match: null, emptyResult: null, firstRejection: null });
|
|
360
|
+
return searchState.match || searchState.emptyResult || searchState.firstRejection;
|
|
305
361
|
},
|
|
306
362
|
{ concurrency: LIST_BOOKINGS_CONCURRENCY },
|
|
307
363
|
);
|
|
@@ -315,11 +371,13 @@ const searchItineraries = async ({
|
|
|
315
371
|
replyObjs.push(result.value);
|
|
316
372
|
return;
|
|
317
373
|
}
|
|
318
|
-
const
|
|
374
|
+
const keyObj = allSearches[idx][0];
|
|
319
375
|
const { reason } = result;
|
|
320
376
|
const errMsg = reason instanceof Error ? reason.message : String(reason);
|
|
321
377
|
rejectedSearches.push(reason);
|
|
322
|
-
|
|
378
|
+
if (!Object.prototype.hasOwnProperty.call(keyObj, 'AgentRef')) {
|
|
379
|
+
console.warn('[tourplan] ListBookingsRequest failed', keyObj, errMsg);
|
|
380
|
+
}
|
|
323
381
|
});
|
|
324
382
|
|
|
325
383
|
if (!replyObjs.length && rejectedSearches.length) {
|
package/itinerary-search.test.js
CHANGED
|
@@ -109,6 +109,52 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
109
109
|
expect(result).toEqual({ bookings: [] });
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
it('tries the raw AgentRef before the provider-safe value after a miss', async () => {
|
|
113
|
+
const rawAgentReference = 'X'.repeat(61);
|
|
114
|
+
const callTourplan = jest.fn(async ({ model }) => {
|
|
115
|
+
if (model.ListBookingsRequest) {
|
|
116
|
+
return {
|
|
117
|
+
ListBookingsReply: {
|
|
118
|
+
BookingHeaders: {
|
|
119
|
+
BookingHeader: model.ListBookingsRequest.AgentRef === 'X'.repeat(60)
|
|
120
|
+
? [{ BookingId: '778' }]
|
|
121
|
+
: [],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { GetBookingReply: { BookingId: '778' } };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = await runSearch({ agentReferenceIds: [rawAgentReference] }, callTourplan);
|
|
130
|
+
|
|
131
|
+
expect(listRequestsFrom(callTourplan).map(request => request.AgentRef)).toEqual([
|
|
132
|
+
rawAgentReference,
|
|
133
|
+
'X'.repeat(60),
|
|
134
|
+
]);
|
|
135
|
+
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['778']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not try the provider-safe AgentRef after the raw value matches', async () => {
|
|
139
|
+
const rawAgentReference = 'Müller – external booking';
|
|
140
|
+
const callTourplan = jest.fn(async ({ model }) => {
|
|
141
|
+
if (model.ListBookingsRequest) {
|
|
142
|
+
return {
|
|
143
|
+
ListBookingsReply: {
|
|
144
|
+
BookingHeaders: { BookingHeader: [{ BookingId: '779' }] },
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { GetBookingReply: { BookingId: '779' } };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = await runSearch({ agentReferenceIds: [rawAgentReference] }, callTourplan);
|
|
152
|
+
|
|
153
|
+
expect(listRequestsFrom(callTourplan).map(request => request.AgentRef))
|
|
154
|
+
.toEqual([rawAgentReference]);
|
|
155
|
+
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['779']);
|
|
156
|
+
});
|
|
157
|
+
|
|
112
158
|
it('normalizes array values and deduplicates AgentRef searches and booking results', async () => {
|
|
113
159
|
const callTourplan = jest.fn(async ({ model }) => {
|
|
114
160
|
const listRequest = model.ListBookingsRequest;
|
|
@@ -160,12 +206,29 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
160
206
|
expect(getBookingIds).toEqual(['601', '602']);
|
|
161
207
|
});
|
|
162
208
|
|
|
209
|
+
it('deduplicates provider-safe AgentRef variants across inputs', async () => {
|
|
210
|
+
const rawAgentReference = 'A'.repeat(61);
|
|
211
|
+
const callTourplan = jest.fn(async () => ({
|
|
212
|
+
ListBookingsReply: { BookingHeaders: { BookingHeader: [] } },
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
const result = await runSearch({
|
|
216
|
+
agentReferenceIds: [rawAgentReference, 'A'.repeat(60)],
|
|
217
|
+
}, callTourplan);
|
|
218
|
+
|
|
219
|
+
expect(listRequestsFrom(callTourplan).map(request => request.AgentRef)).toEqual([
|
|
220
|
+
rawAgentReference,
|
|
221
|
+
'A'.repeat(60),
|
|
222
|
+
]);
|
|
223
|
+
expect(result).toEqual({ bookings: [] });
|
|
224
|
+
});
|
|
225
|
+
|
|
163
226
|
it('keeps successful AgentRef results when another fan-out request fails', async () => {
|
|
164
227
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
165
228
|
const callTourplan = jest.fn(async ({ model }) => {
|
|
166
229
|
const listRequest = model.ListBookingsRequest;
|
|
167
230
|
if (listRequest && listRequest.AgentRef === 'FAIL') {
|
|
168
|
-
throw new Error('Request failed with status code 500
|
|
231
|
+
throw new Error('Request failed with status code 500 for FAIL');
|
|
169
232
|
}
|
|
170
233
|
if (listRequest && listRequest.AgentRef === 'GOOD') {
|
|
171
234
|
return {
|
|
@@ -182,10 +245,11 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
182
245
|
|
|
183
246
|
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['801']);
|
|
184
247
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
185
|
-
'[tourplan] ListBookingsRequest failed',
|
|
186
|
-
|
|
187
|
-
'Request failed with status code 500
|
|
248
|
+
'[tourplan] AgentRef ListBookingsRequest failed status=%s error=%s',
|
|
249
|
+
'unknown',
|
|
250
|
+
'Request failed with status code 500 for [redacted]',
|
|
188
251
|
);
|
|
252
|
+
expect(JSON.stringify(warnSpy.mock.calls)).not.toContain('FAIL');
|
|
189
253
|
} finally {
|
|
190
254
|
warnSpy.mockRestore();
|
|
191
255
|
}
|
|
@@ -254,10 +318,11 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
254
318
|
.toEqual([...agentReferenceIds].sort());
|
|
255
319
|
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['901']);
|
|
256
320
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
257
|
-
'[tourplan] ListBookingsRequest failed',
|
|
258
|
-
|
|
321
|
+
'[tourplan] AgentRef ListBookingsRequest failed status=%s error=%s',
|
|
322
|
+
'unknown',
|
|
259
323
|
'Request failed with status code 500: failed AgentRef',
|
|
260
324
|
);
|
|
325
|
+
expect(JSON.stringify(warnSpy.mock.calls)).not.toContain('REF-7');
|
|
261
326
|
} finally {
|
|
262
327
|
pendingListRequests.splice(0).forEach(({ settle }) => settle());
|
|
263
328
|
warnSpy.mockRestore();
|
|
@@ -305,6 +370,7 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
305
370
|
it.each([
|
|
306
371
|
['an empty list', []],
|
|
307
372
|
['blank and absent entries', [' ', null, undefined]],
|
|
373
|
+
['values removed by XML sanitization', ['\u0001']],
|
|
308
374
|
['malformed entries', [{}, true, false, NaN, Infinity]],
|
|
309
375
|
['an explicit undefined scalar', undefined],
|
|
310
376
|
])('returns empty without a HostConnect request for %s', async (description, agentReferenceIds) => {
|
|
@@ -321,6 +387,15 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
321
387
|
expect(callTourplan).not.toHaveBeenCalled();
|
|
322
388
|
});
|
|
323
389
|
|
|
390
|
+
it('returns empty without a HostConnect request for a blank bookingId', async () => {
|
|
391
|
+
const callTourplan = jest.fn();
|
|
392
|
+
|
|
393
|
+
const result = await runSearch({ bookingId: ' ' }, callTourplan);
|
|
394
|
+
|
|
395
|
+
expect(result).toEqual({ bookings: [] });
|
|
396
|
+
expect(callTourplan).not.toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
|
|
324
399
|
it.each([
|
|
325
400
|
['bookingReferenceIds', { bookingReferenceIds: ['LEGACY-REF'] }, 1],
|
|
326
401
|
['bookingId', { bookingId: '12345' }, 3],
|
|
@@ -344,6 +419,30 @@ describe('searchItineraries agentReferenceIds', () => {
|
|
|
344
419
|
expect(result).toEqual({ bookings: [] });
|
|
345
420
|
expect(callTourplan).toHaveBeenCalledTimes(expectedRequests);
|
|
346
421
|
});
|
|
422
|
+
|
|
423
|
+
it('uses raw and provider-safe AgentRef variants for a bookingId fallback', async () => {
|
|
424
|
+
const rawBookingId = 'Z'.repeat(61);
|
|
425
|
+
const callTourplan = jest.fn(async ({ model }) => {
|
|
426
|
+
if (model.ListBookingsRequest) {
|
|
427
|
+
const { AgentRef } = model.ListBookingsRequest;
|
|
428
|
+
return {
|
|
429
|
+
ListBookingsReply: {
|
|
430
|
+
BookingHeaders: {
|
|
431
|
+
BookingHeader: AgentRef === 'Z'.repeat(60) ? [{ BookingId: '880' }] : [],
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { GetBookingReply: { BookingId: '880' } };
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const result = await runSearch({ bookingId: rawBookingId }, callTourplan);
|
|
440
|
+
|
|
441
|
+
expect(listRequestsFrom(callTourplan)
|
|
442
|
+
.filter(request => Object.prototype.hasOwnProperty.call(request, 'AgentRef'))
|
|
443
|
+
.map(request => request.AgentRef)).toEqual([rawBookingId, 'Z'.repeat(60)]);
|
|
444
|
+
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['880']);
|
|
445
|
+
});
|
|
347
446
|
});
|
|
348
447
|
|
|
349
448
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
package/utils.js
CHANGED
|
@@ -11,6 +11,7 @@ const passengerTypeMap = {
|
|
|
11
11
|
|
|
12
12
|
// Constants not exported
|
|
13
13
|
const BAD_XML_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007f-\u0084\u0086-\u009f\uD800-\uDFFF\uFDD0-\uFDFF\uFFFF\uC008\uFEFF\u00DF]/g; // eslint-disable-line no-control-regex
|
|
14
|
+
const MAX_AGENT_REFERENCE_LENGTH = 60;
|
|
14
15
|
|
|
15
16
|
const hostConnectXmlOptions = {
|
|
16
17
|
prettyPrinting: { enabled: false },
|
|
@@ -71,6 +72,16 @@ const escapeInvalidXmlChars = str => {
|
|
|
71
72
|
.replace(BAD_XML_CHARS, '');
|
|
72
73
|
};
|
|
73
74
|
|
|
75
|
+
const normalizeAgentReference = value => {
|
|
76
|
+
if (value == null || value === '') return '';
|
|
77
|
+
const normalized = String(value).trim();
|
|
78
|
+
if (!normalized) return '';
|
|
79
|
+
return Array.from(escapeInvalidXmlChars(normalized))
|
|
80
|
+
.slice(0, MAX_AGENT_REFERENCE_LENGTH)
|
|
81
|
+
.join('')
|
|
82
|
+
.trim();
|
|
83
|
+
};
|
|
84
|
+
|
|
74
85
|
const isEnabled = value => {
|
|
75
86
|
if (value === true || value === 1) return true;
|
|
76
87
|
const normalized = String(value == null ? '' : value).trim().toLowerCase();
|
|
@@ -208,6 +219,7 @@ const getRoomConfigs = (paxConfigs, noPaxList) => {
|
|
|
208
219
|
|
|
209
220
|
module.exports = {
|
|
210
221
|
escapeInvalidXmlChars,
|
|
222
|
+
normalizeAgentReference,
|
|
211
223
|
getRoomConfigs,
|
|
212
224
|
wildcardMatch,
|
|
213
225
|
CUSTOM_RATE_ID_NAME,
|
package/utils.test.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
escapeInvalidXmlChars,
|
|
5
|
+
normalizeAgentReference,
|
|
5
6
|
getOptionInfoTag,
|
|
6
7
|
getRoomConfigs,
|
|
7
8
|
} = require('./utils');
|
|
@@ -142,6 +143,21 @@ describe('escapeInvalidXmlChars', () => {
|
|
|
142
143
|
});
|
|
143
144
|
});
|
|
144
145
|
|
|
146
|
+
describe('normalizeAgentReference', () => {
|
|
147
|
+
it('trims and caps the sanitized value at 60 characters', () => {
|
|
148
|
+
expect(normalizeAgentReference(` ${'A'.repeat(61)} `)).toBe('A'.repeat(60));
|
|
149
|
+
expect(normalizeAgentReference('Ä'.repeat(40))).toBe('Ae'.repeat(30));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('removes invalid XML characters before applying the cap', () => {
|
|
153
|
+
expect(normalizeAgentReference(`${'A'.repeat(59)}\u0000BC`)).toBe(`${'A'.repeat(59)}B`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns an empty value when sanitization removes the selected source', () => {
|
|
157
|
+
expect(normalizeAgentReference(' \u0001 ')).toBe('');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
145
161
|
describe('getOptionInfoTag', () => {
|
|
146
162
|
it('uses includeRates for product-search tags when checkType is omitted', () => {
|
|
147
163
|
expect(getOptionInfoTag()).toBe('G');
|