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 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', () => {
@@ -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: escapeInvalidXmlChars(reference),
204
+ ...(agentReference ? { AgentRef: agentReference } : {}),
182
205
  RoomConfigs: getRoomConfigs(paxConfigs),
183
- ...(directLinePayload || {}),
184
- ...(cfvPerService || {}),
206
+ ...directLinePayloadWithoutAgentReference,
207
+ ...customFieldsWithoutAgentReference,
185
208
  },
186
209
  };
187
210
  const replyObj = await callTourplan({
@@ -1,6 +1,10 @@
1
1
  const R = require('ramda');
2
2
  const Promise = require('bluebird');
3
- const { escapeInvalidXmlChars, hostConnectXmlOptions } = require('./utils');
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 searchCriterias = [];
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 normalizedAgentReferenceIds = rawAgentReferenceIds
216
+ const agentReferenceSearchVariants = R.uniqBy(variants => variants[0], rawAgentReferenceIds
203
217
  .filter(ref => typeof ref === 'string' || (typeof ref === 'number' && Number.isFinite(ref)))
204
- .map(ref => escapeInvalidXmlChars(String(ref).trim()))
205
- .filter(Boolean);
206
- const uniqueAgentReferenceIds = R.uniq(normalizedAgentReferenceIds);
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 (uniqueAgentReferenceIds.length) {
215
- searchCriterias = uniqueAgentReferenceIds.map(ref => ({ AgentRef: ref }));
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
- searchCriterias = R.uniq(normalizedBookingReferenceIds).map(ref => ({ Ref: ref }));
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
- searchCriterias = ['BookingId', 'Ref', 'AgentRef'].map(key => ({ [key]: escapeInvalidXmlChars(bookingId) }));
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
- searchCriterias.push({ NameContains: escapeInvalidXmlChars(name) });
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 = (searchCriterias.length ? searchCriterias : [baseSearchFilters])
255
- .map(keyObj => ({ keyObj }));
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 ({ keyObj }) => {
259
- try {
260
- let reply;
277
+ async keyObjs => {
278
+ const searchState = await Promise.reduce(keyObjs, async (state, keyObj) => {
279
+ if (state.match) return state;
261
280
  try {
262
- reply = await callTourplan(getPayload('ListBookingsRequest', {
263
- ...(baseSearchFilters ? { ...baseSearchFilters } : {}),
264
- ...keyObj,
265
- }));
266
- /*
267
- <Reply>
268
- <ListBookingsReply>
269
- <BookingHeaders>
270
- <BookingHeader>
271
- <BookingId>320984</BookingId>
272
- <Ref>ALFI399113</Ref>
273
- <Name>Barbara Solomon x2 2554776</Name>
274
- <NameAlias/>
275
- <QB>B</QB>
276
- <Consult>TEST AGENT OWNER</Consult>
277
- <AgentRef>2554776</AgentRef>
278
- <TravelDate>2025-04-06</TravelDate>
279
- <EnteredDate>2025-01-30</EnteredDate>
280
- <BookingStatus>Quotation iCom CNX</BookingStatus>
281
- <BookingType>F</BookingType>
282
- <IsInternetBooking>Y</IsInternetBooking>
283
- <Currency>GBP</Currency>
284
- <TotalPrice>1016738</TotalPrice>
285
- </BookingHeader>
286
- </BookingHeaders>
287
- </ListBookingsReply>
288
- </Reply>
289
- */
290
- } catch (err) {
291
- const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
292
- if (hasAgentReferenceIds) {
293
- throw err instanceof Error ? err : Error(errMsg);
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
- if (errMsg.includes('Request failed with status code')) {
296
- throw Error(errMsg);
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
- // if it's not server error, we just considered as no booking is found
299
- reply = { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
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
- return { status: 'fulfilled', value: reply };
302
- } catch (reason) {
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 { keyObj } = allSearches[idx];
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
- console.warn('[tourplan] ListBookingsRequest failed', keyObj, errMsg);
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) {
@@ -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: failed AgentRef');
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
- { AgentRef: 'FAIL' },
187
- 'Request failed with status code 500: failed AgentRef',
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
- { AgentRef: 'REF-7' },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.138",
3
+ "version": "1.0.139",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
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');