ti2-tourplan 1.0.134 → 1.0.136
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/__snapshots__/index.test.js.snap +1 -0
- package/index.test.js +23 -0
- package/itinerary-products-search.js +3 -2
- package/itinerary-search.js +42 -30
- package/itinerary-search.test.js +323 -1
- package/option-metadata.js +44 -0
- package/option-metadata.test.js +42 -0
- package/package.json +1 -1
package/index.test.js
CHANGED
|
@@ -867,6 +867,7 @@ describe('search tests', () => {
|
|
|
867
867
|
},
|
|
868
868
|
});
|
|
869
869
|
expect(R.path(['products', 0, 'options', 0, 'city'], retVal)).toBe('London');
|
|
870
|
+
expect(R.path(['products', 0, 'options', 0, 'inclusions'], retVal)).toEqual([]);
|
|
870
871
|
expect(retVal).toMatchSnapshot();
|
|
871
872
|
});
|
|
872
873
|
|
|
@@ -1757,6 +1758,28 @@ describe('search tests', () => {
|
|
|
1757
1758
|
expect(retVal.bookings[0].bookingId).toBe('316559');
|
|
1758
1759
|
});
|
|
1759
1760
|
|
|
1761
|
+
it('searchItineraries surfaces AgentRef HostConnect application failures', async () => {
|
|
1762
|
+
const actualApp = new Plugin();
|
|
1763
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1764
|
+
const applicationErrorAxios = jest.fn(async () => ({
|
|
1765
|
+
data: '<Reply><ErrorReply><Error>Invalid Agent credentials</Error></ErrorReply></Reply>',
|
|
1766
|
+
}));
|
|
1767
|
+
|
|
1768
|
+
try {
|
|
1769
|
+
await expect(actualApp.searchItineraries({
|
|
1770
|
+
axios: applicationErrorAxios,
|
|
1771
|
+
token,
|
|
1772
|
+
typeDefsAndQueries,
|
|
1773
|
+
payload: {
|
|
1774
|
+
agentReferenceIds: ['AGENT-REF-1', 'AGENT-REF-2'],
|
|
1775
|
+
},
|
|
1776
|
+
})).rejects.toThrow('ListBookingsRequest failed: Invalid Agent credentials');
|
|
1777
|
+
expect(applicationErrorAxios).toHaveBeenCalledTimes(2);
|
|
1778
|
+
} finally {
|
|
1779
|
+
warnSpy.mockRestore();
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1760
1783
|
it('searchItineraries bookingReferenceIds accepts a single string', async () => {
|
|
1761
1784
|
axios.mockImplementation(getFixture);
|
|
1762
1785
|
const retVal = await app.searchItineraries({
|
|
@@ -7,6 +7,7 @@ const { getCachedLocations } = require('./tp-helpers/locations');
|
|
|
7
7
|
const { getCachedServices } = require('./tp-helpers/services');
|
|
8
8
|
const { getCachedDestinationCountries } = require('./tp-helpers/system-settings');
|
|
9
9
|
const { enrichOptionWithCodeTables } = require('./tp-helpers/option-enrichment');
|
|
10
|
+
const { addStructuredOptionMetadata } = require('./option-metadata');
|
|
10
11
|
const {
|
|
11
12
|
isEnabled,
|
|
12
13
|
normalizePickupPoints,
|
|
@@ -260,7 +261,7 @@ const searchProductsForItinerary = async ({
|
|
|
260
261
|
|| R.path(['PickupPoints'], rawOption),
|
|
261
262
|
)
|
|
262
263
|
: undefined;
|
|
263
|
-
return {
|
|
264
|
+
return addStructuredOptionMetadata({
|
|
264
265
|
...R.omit(['city', 'country', 'rateContext'], currentOption),
|
|
265
266
|
...(city ? { city } : {}),
|
|
266
267
|
...(country ? { country } : {}),
|
|
@@ -269,7 +270,7 @@ const searchProductsForItinerary = async ({
|
|
|
269
270
|
...(R.path([currentOption.optionId], optionRatesByOptionId)
|
|
270
271
|
? { optRates: R.path([currentOption.optionId], optionRatesByOptionId) }
|
|
271
272
|
: {}),
|
|
272
|
-
};
|
|
273
|
+
});
|
|
273
274
|
}),
|
|
274
275
|
}));
|
|
275
276
|
return {
|
package/itinerary-search.js
CHANGED
|
@@ -5,6 +5,8 @@ const { translateItineraryBooking } = require('./resolvers/itinerary');
|
|
|
5
5
|
|
|
6
6
|
/** Years to extend the travel window when start or end is missing. */
|
|
7
7
|
const TRAVEL_WINDOW_SPAN_YEARS = 2;
|
|
8
|
+
const LIST_BOOKINGS_CONCURRENCY = 10;
|
|
9
|
+
const MAX_AGENT_REFERENCE_IDS = 20;
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Extract the date prefix from a string in the format YYYY-MM-DD.
|
|
@@ -159,7 +161,10 @@ const searchItineraries = async ({
|
|
|
159
161
|
itineraryBookingTypeDefs,
|
|
160
162
|
itineraryBookingQuery,
|
|
161
163
|
},
|
|
162
|
-
payload
|
|
164
|
+
payload,
|
|
165
|
+
callTourplan,
|
|
166
|
+
}) => {
|
|
167
|
+
const {
|
|
163
168
|
purchaseDateStart,
|
|
164
169
|
purchaseDateEnd,
|
|
165
170
|
travelDateStart,
|
|
@@ -167,9 +172,8 @@ const searchItineraries = async ({
|
|
|
167
172
|
bookingReferenceIds,
|
|
168
173
|
bookingId,
|
|
169
174
|
name,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}) => {
|
|
175
|
+
agentReferenceIds,
|
|
176
|
+
} = payload;
|
|
173
177
|
const getPayload = (RequestType, RequestInput) => ({
|
|
174
178
|
model: {
|
|
175
179
|
[RequestType]: {
|
|
@@ -188,11 +192,28 @@ const searchItineraries = async ({
|
|
|
188
192
|
let baseSearchFilters = null;
|
|
189
193
|
|
|
190
194
|
// Step1: Build search criterias based on the provided search criteria.
|
|
195
|
+
const hasAgentReferenceIds = Object.prototype.hasOwnProperty.call(payload, 'agentReferenceIds');
|
|
196
|
+
const rawAgentReferenceIds = (
|
|
197
|
+
Array.isArray(agentReferenceIds) ? agentReferenceIds : [agentReferenceIds]
|
|
198
|
+
);
|
|
199
|
+
if (hasAgentReferenceIds && rawAgentReferenceIds.length > MAX_AGENT_REFERENCE_IDS) {
|
|
200
|
+
return { bookings: [] };
|
|
201
|
+
}
|
|
202
|
+
const normalizedAgentReferenceIds = rawAgentReferenceIds
|
|
203
|
+
.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) {
|
|
208
|
+
return { bookings: [] };
|
|
209
|
+
}
|
|
191
210
|
const normalizedBookingReferenceIds = (
|
|
192
211
|
Array.isArray(bookingReferenceIds) ? bookingReferenceIds : [bookingReferenceIds]
|
|
193
212
|
).filter(v => v != null).map(v => escapeInvalidXmlChars(String(v).trim())).filter(Boolean);
|
|
194
213
|
|
|
195
|
-
if (
|
|
214
|
+
if (uniqueAgentReferenceIds.length) {
|
|
215
|
+
searchCriterias = uniqueAgentReferenceIds.map(ref => ({ AgentRef: ref }));
|
|
216
|
+
} else if (normalizedBookingReferenceIds.length) {
|
|
196
217
|
// if bookingReferenceIds are provided other search criteria are ignored
|
|
197
218
|
searchCriterias = R.uniq(normalizedBookingReferenceIds).map(ref => ({ Ref: ref }));
|
|
198
219
|
} else if (bookingId) {
|
|
@@ -230,10 +251,12 @@ const searchItineraries = async ({
|
|
|
230
251
|
}
|
|
231
252
|
|
|
232
253
|
// Step4: Fetch for bookings based on the search criterias.
|
|
233
|
-
const allSearches = searchCriterias.length
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
254
|
+
const allSearches = (searchCriterias.length ? searchCriterias : [baseSearchFilters])
|
|
255
|
+
.map(keyObj => ({ keyObj }));
|
|
256
|
+
const settledSearches = await Promise.map(
|
|
257
|
+
allSearches,
|
|
258
|
+
async ({ keyObj }) => {
|
|
259
|
+
try {
|
|
237
260
|
let reply;
|
|
238
261
|
try {
|
|
239
262
|
reply = await callTourplan(getPayload('ListBookingsRequest', {
|
|
@@ -266,35 +289,24 @@ const searchItineraries = async ({
|
|
|
266
289
|
*/
|
|
267
290
|
} catch (err) {
|
|
268
291
|
const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
|
|
292
|
+
if (hasAgentReferenceIds) {
|
|
293
|
+
throw err instanceof Error ? err : Error(errMsg);
|
|
294
|
+
}
|
|
269
295
|
if (errMsg.includes('Request failed with status code')) {
|
|
270
296
|
throw Error(errMsg);
|
|
271
297
|
}
|
|
272
298
|
// if it's not server error, we just considered as no booking is found
|
|
273
299
|
reply = { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
|
|
274
300
|
}
|
|
275
|
-
return reply;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
{
|
|
283
|
-
keyObj: baseSearchFilters,
|
|
284
|
-
promise: (async () => {
|
|
285
|
-
try {
|
|
286
|
-
return await callTourplan(getPayload('ListBookingsRequest', baseSearchFilters));
|
|
287
|
-
} catch (err) {
|
|
288
|
-
const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
|
|
289
|
-
if (errMsg.includes('Request failed with status code')) throw Error(errMsg);
|
|
290
|
-
return { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
|
|
291
|
-
}
|
|
292
|
-
})(),
|
|
293
|
-
},
|
|
294
|
-
];
|
|
301
|
+
return { status: 'fulfilled', value: reply };
|
|
302
|
+
} catch (reason) {
|
|
303
|
+
return { status: 'rejected', reason };
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
{ concurrency: LIST_BOOKINGS_CONCURRENCY },
|
|
307
|
+
);
|
|
295
308
|
|
|
296
309
|
// Step5: Get full booking details for each booking.
|
|
297
|
-
const settledSearches = await global.Promise.allSettled(allSearches.map(({ promise }) => promise));
|
|
298
310
|
const replyObjs = [];
|
|
299
311
|
const rejectedSearches = [];
|
|
300
312
|
|
package/itinerary-search.test.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
/* globals describe, it, expect, beforeAll, afterAll */
|
|
1
|
+
/* globals describe, it, expect, jest, beforeAll, afterAll */
|
|
2
|
+
|
|
3
|
+
jest.mock('./resolvers/itinerary', () => ({
|
|
4
|
+
translateItineraryBooking: jest.fn(async ({ rootValue }) => rootValue),
|
|
5
|
+
}));
|
|
2
6
|
|
|
3
7
|
const {
|
|
8
|
+
searchItineraries,
|
|
4
9
|
resolveTravelDateWindow,
|
|
5
10
|
resolvePurchaseDateWindow,
|
|
6
11
|
} = require('./itinerary-search');
|
|
7
12
|
|
|
13
|
+
const realSetImmediate = setImmediate;
|
|
14
|
+
|
|
8
15
|
/**
|
|
9
16
|
* Freeze time so "today-based" default windows are deterministic.
|
|
10
17
|
* TRAVEL_WINDOW_SPAN_YEARS = 2, so the full span is 24 months.
|
|
@@ -24,6 +31,321 @@ afterAll(() => {
|
|
|
24
31
|
jest.useRealTimers();
|
|
25
32
|
});
|
|
26
33
|
|
|
34
|
+
describe('searchItineraries agentReferenceIds', () => {
|
|
35
|
+
const token = {
|
|
36
|
+
hostConnectAgentID: 'agent-id',
|
|
37
|
+
hostConnectAgentPassword: 'agent-password',
|
|
38
|
+
hostConnectEndpoint: 'https://example.test/hostconnect',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const runSearch = (payload, callTourplan) => searchItineraries({
|
|
42
|
+
token,
|
|
43
|
+
axios: jest.fn(),
|
|
44
|
+
typeDefsAndQueries: {
|
|
45
|
+
itineraryBookingTypeDefs: {},
|
|
46
|
+
itineraryBookingQuery: '',
|
|
47
|
+
},
|
|
48
|
+
payload,
|
|
49
|
+
callTourplan,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const listRequestsFrom = callTourplan => callTourplan.mock.calls
|
|
53
|
+
.map(([request]) => request.model.ListBookingsRequest)
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
|
|
56
|
+
it('treats a numeric agentReferenceIds value as an exact AgentRef with explicit precedence', async () => {
|
|
57
|
+
const callTourplan = jest.fn(async ({ model }) => {
|
|
58
|
+
if (model.ListBookingsRequest) {
|
|
59
|
+
return {
|
|
60
|
+
ListBookingsReply: {
|
|
61
|
+
BookingHeaders: { BookingHeader: [{ BookingId: '777' }] },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { GetBookingReply: { BookingId: '777' } };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await runSearch({
|
|
69
|
+
agentReferenceIds: 501,
|
|
70
|
+
bookingReferenceIds: ['IGNORE-REF'],
|
|
71
|
+
bookingId: '501',
|
|
72
|
+
name: 'Ignore name',
|
|
73
|
+
travelDateStart: '2026-01-01',
|
|
74
|
+
travelDateEnd: '2026-12-31',
|
|
75
|
+
purchaseDateStart: '2026-02-01',
|
|
76
|
+
purchaseDateEnd: '2026-03-01',
|
|
77
|
+
}, callTourplan);
|
|
78
|
+
|
|
79
|
+
const listRequests = listRequestsFrom(callTourplan);
|
|
80
|
+
expect(listRequests).toEqual([{
|
|
81
|
+
AgentID: 'agent-id',
|
|
82
|
+
Password: 'agent-password',
|
|
83
|
+
AgentRef: '501',
|
|
84
|
+
}]);
|
|
85
|
+
expect(listRequests).not.toEqual(expect.arrayContaining([
|
|
86
|
+
expect.objectContaining({ BookingId: '501' }),
|
|
87
|
+
]));
|
|
88
|
+
expect(listRequests).not.toEqual(expect.arrayContaining([
|
|
89
|
+
expect.objectContaining({ Ref: '501' }),
|
|
90
|
+
]));
|
|
91
|
+
expect(result.bookings).toEqual([{
|
|
92
|
+
BookingId: '777',
|
|
93
|
+
agentId: 'agent-id',
|
|
94
|
+
}]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('normalizes a scalar string agentReferenceIds value', async () => {
|
|
98
|
+
const callTourplan = jest.fn(async () => ({
|
|
99
|
+
ListBookingsReply: { BookingHeaders: { BookingHeader: [] } },
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
const result = await runSearch({ agentReferenceIds: ' REF-SCALAR ' }, callTourplan);
|
|
103
|
+
|
|
104
|
+
expect(listRequestsFrom(callTourplan)).toEqual([{
|
|
105
|
+
AgentID: 'agent-id',
|
|
106
|
+
Password: 'agent-password',
|
|
107
|
+
AgentRef: 'REF-SCALAR',
|
|
108
|
+
}]);
|
|
109
|
+
expect(result).toEqual({ bookings: [] });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('normalizes array values and deduplicates AgentRef searches and booking results', async () => {
|
|
113
|
+
const callTourplan = jest.fn(async ({ model }) => {
|
|
114
|
+
const listRequest = model.ListBookingsRequest;
|
|
115
|
+
if (listRequest && listRequest.AgentRef === 'REF-A') {
|
|
116
|
+
return {
|
|
117
|
+
ListBookingsReply: {
|
|
118
|
+
BookingHeaders: {
|
|
119
|
+
BookingHeader: [{ BookingId: '601' }, { BookingId: '602' }],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (listRequest && listRequest.AgentRef === '700') {
|
|
125
|
+
return {
|
|
126
|
+
ListBookingsReply: {
|
|
127
|
+
BookingHeaders: { BookingHeader: [{ BookingId: '602' }] },
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (model.GetBookingRequest) {
|
|
132
|
+
return {
|
|
133
|
+
GetBookingReply: { BookingId: model.GetBookingRequest.BookingId },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Unexpected request: ${JSON.stringify(model)}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const result = await runSearch({
|
|
140
|
+
agentReferenceIds: [' REF-A ', 700, 'REF-A', null, {}, true, Infinity],
|
|
141
|
+
}, callTourplan);
|
|
142
|
+
|
|
143
|
+
expect(listRequestsFrom(callTourplan)).toEqual([
|
|
144
|
+
{
|
|
145
|
+
AgentID: 'agent-id',
|
|
146
|
+
Password: 'agent-password',
|
|
147
|
+
AgentRef: 'REF-A',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
AgentID: 'agent-id',
|
|
151
|
+
Password: 'agent-password',
|
|
152
|
+
AgentRef: '700',
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['601', '602']);
|
|
156
|
+
const getBookingIds = callTourplan.mock.calls
|
|
157
|
+
.map(([request]) => request.model.GetBookingRequest)
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.map(request => request.BookingId);
|
|
160
|
+
expect(getBookingIds).toEqual(['601', '602']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps successful AgentRef results when another fan-out request fails', async () => {
|
|
164
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
165
|
+
const callTourplan = jest.fn(async ({ model }) => {
|
|
166
|
+
const listRequest = model.ListBookingsRequest;
|
|
167
|
+
if (listRequest && listRequest.AgentRef === 'FAIL') {
|
|
168
|
+
throw new Error('Request failed with status code 500: failed AgentRef');
|
|
169
|
+
}
|
|
170
|
+
if (listRequest && listRequest.AgentRef === 'GOOD') {
|
|
171
|
+
return {
|
|
172
|
+
ListBookingsReply: {
|
|
173
|
+
BookingHeaders: { BookingHeader: [{ BookingId: '801' }] },
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { GetBookingReply: { BookingId: model.GetBookingRequest.BookingId } };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const result = await runSearch({ agentReferenceIds: ['FAIL', 'GOOD'] }, callTourplan);
|
|
182
|
+
|
|
183
|
+
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['801']);
|
|
184
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
185
|
+
'[tourplan] ListBookingsRequest failed',
|
|
186
|
+
{ AgentRef: 'FAIL' },
|
|
187
|
+
'Request failed with status code 500: failed AgentRef',
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
warnSpy.mockRestore();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('limits concurrent AgentRef ListBookings requests while attempting every reference', async () => {
|
|
195
|
+
const agentReferenceIds = Array.from({ length: 20 }, (_, idx) => `REF-${idx}`);
|
|
196
|
+
const pendingListRequests = [];
|
|
197
|
+
let activeListRequests = 0;
|
|
198
|
+
let maxActiveListRequests = 0;
|
|
199
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
200
|
+
const callTourplan = jest.fn(({ model }) => {
|
|
201
|
+
if (model.GetBookingRequest) {
|
|
202
|
+
return global.Promise.resolve({
|
|
203
|
+
GetBookingReply: { BookingId: model.GetBookingRequest.BookingId },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { AgentRef } = model.ListBookingsRequest;
|
|
208
|
+
activeListRequests += 1;
|
|
209
|
+
maxActiveListRequests = Math.max(maxActiveListRequests, activeListRequests);
|
|
210
|
+
return new global.Promise((resolve, reject) => {
|
|
211
|
+
pendingListRequests.push({
|
|
212
|
+
AgentRef,
|
|
213
|
+
settle: () => {
|
|
214
|
+
activeListRequests -= 1;
|
|
215
|
+
if (AgentRef === 'REF-7') {
|
|
216
|
+
reject(new Error('Request failed with status code 500: failed AgentRef'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
resolve({
|
|
220
|
+
ListBookingsReply: {
|
|
221
|
+
BookingHeaders: {
|
|
222
|
+
BookingHeader: AgentRef === 'REF-19' ? [{ BookingId: '901' }] : [],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const searchPromise = runSearch({ agentReferenceIds }, callTourplan);
|
|
233
|
+
const flushAsyncWork = remainingTicks => (
|
|
234
|
+
new global.Promise(resolve => realSetImmediate(resolve))
|
|
235
|
+
.then(() => (remainingTicks > 1 ? flushAsyncWork(remainingTicks - 1) : undefined))
|
|
236
|
+
);
|
|
237
|
+
const settleStartedListRequests = async () => {
|
|
238
|
+
if (listRequestsFrom(callTourplan).length >= agentReferenceIds.length) return;
|
|
239
|
+
expect(activeListRequests).toBeLessThanOrEqual(10);
|
|
240
|
+
expect(pendingListRequests.length).toBeGreaterThan(0);
|
|
241
|
+
pendingListRequests.splice(0).forEach(({ settle }) => settle());
|
|
242
|
+
await flushAsyncWork(4);
|
|
243
|
+
await settleStartedListRequests();
|
|
244
|
+
};
|
|
245
|
+
await flushAsyncWork(4);
|
|
246
|
+
await settleStartedListRequests();
|
|
247
|
+
pendingListRequests.splice(0).forEach(({ settle }) => settle());
|
|
248
|
+
await flushAsyncWork(4);
|
|
249
|
+
|
|
250
|
+
const result = await searchPromise;
|
|
251
|
+
|
|
252
|
+
expect(maxActiveListRequests).toBeLessThanOrEqual(10);
|
|
253
|
+
expect(listRequestsFrom(callTourplan).map(request => request.AgentRef).sort())
|
|
254
|
+
.toEqual([...agentReferenceIds].sort());
|
|
255
|
+
expect(result.bookings.map(booking => booking.BookingId)).toEqual(['901']);
|
|
256
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
257
|
+
'[tourplan] ListBookingsRequest failed',
|
|
258
|
+
{ AgentRef: 'REF-7' },
|
|
259
|
+
'Request failed with status code 500: failed AgentRef',
|
|
260
|
+
);
|
|
261
|
+
} finally {
|
|
262
|
+
pendingListRequests.splice(0).forEach(({ settle }) => settle());
|
|
263
|
+
warnSpy.mockRestore();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('fails closed when more than 20 AgentRef values are provided', async () => {
|
|
268
|
+
const callTourplan = jest.fn();
|
|
269
|
+
const agentReferenceIds = Array.from({ length: 21 }, (_, idx) => `REF-${idx}`);
|
|
270
|
+
|
|
271
|
+
const result = await runSearch({
|
|
272
|
+
agentReferenceIds,
|
|
273
|
+
bookingId: 'SHOULD-NOT-FALL-BACK',
|
|
274
|
+
}, callTourplan);
|
|
275
|
+
|
|
276
|
+
expect(result).toEqual({ bookings: [] });
|
|
277
|
+
expect(callTourplan).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('counts duplicate AgentRef values toward the hard input limit', async () => {
|
|
281
|
+
const callTourplan = jest.fn();
|
|
282
|
+
|
|
283
|
+
const result = await runSearch({
|
|
284
|
+
agentReferenceIds: Array(21).fill('REF-DUPLICATE'),
|
|
285
|
+
}, callTourplan);
|
|
286
|
+
|
|
287
|
+
expect(result).toEqual({ bookings: [] });
|
|
288
|
+
expect(callTourplan).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('counts malformed entries toward the raw AgentRef input limit', async () => {
|
|
292
|
+
const callTourplan = jest.fn();
|
|
293
|
+
|
|
294
|
+
const result = await runSearch({
|
|
295
|
+
agentReferenceIds: [
|
|
296
|
+
...Array.from({ length: 20 }, (_, idx) => `REF-${idx}`),
|
|
297
|
+
null,
|
|
298
|
+
],
|
|
299
|
+
}, callTourplan);
|
|
300
|
+
|
|
301
|
+
expect(result).toEqual({ bookings: [] });
|
|
302
|
+
expect(callTourplan).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it.each([
|
|
306
|
+
['an empty list', []],
|
|
307
|
+
['blank and absent entries', [' ', null, undefined]],
|
|
308
|
+
['malformed entries', [{}, true, false, NaN, Infinity]],
|
|
309
|
+
['an explicit undefined scalar', undefined],
|
|
310
|
+
])('returns empty without a HostConnect request for %s', async (description, agentReferenceIds) => {
|
|
311
|
+
const callTourplan = jest.fn();
|
|
312
|
+
|
|
313
|
+
const result = await runSearch({
|
|
314
|
+
agentReferenceIds,
|
|
315
|
+
bookingId: 'SHOULD-NOT-FALL-BACK',
|
|
316
|
+
bookingReferenceIds: ['SHOULD-NOT-FALL-BACK'],
|
|
317
|
+
name: 'Should not fall back',
|
|
318
|
+
}, callTourplan);
|
|
319
|
+
|
|
320
|
+
expect(result).toEqual({ bookings: [] });
|
|
321
|
+
expect(callTourplan).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it.each([
|
|
325
|
+
['bookingReferenceIds', { bookingReferenceIds: ['LEGACY-REF'] }, 1],
|
|
326
|
+
['bookingId', { bookingId: '12345' }, 3],
|
|
327
|
+
['name', { name: 'Legacy Booking' }, 1],
|
|
328
|
+
[
|
|
329
|
+
'travel dates',
|
|
330
|
+
{ travelDateStart: '2026-01-01', travelDateEnd: '2026-01-31' },
|
|
331
|
+
1,
|
|
332
|
+
],
|
|
333
|
+
])('keeps legacy %s application failures as empty results', async (
|
|
334
|
+
description,
|
|
335
|
+
payload,
|
|
336
|
+
expectedRequests,
|
|
337
|
+
) => {
|
|
338
|
+
const callTourplan = jest.fn(async () => {
|
|
339
|
+
throw new Error('ListBookingsRequest failed: No matching bookings');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const result = await runSearch(payload, callTourplan);
|
|
343
|
+
|
|
344
|
+
expect(result).toEqual({ bookings: [] });
|
|
345
|
+
expect(callTourplan).toHaveBeenCalledTimes(expectedRequests);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
27
349
|
// ---------------------------------------------------------------------------
|
|
28
350
|
// resolveTravelDateWindow
|
|
29
351
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const BREAKFAST = 'breakfast';
|
|
2
|
+
|
|
3
|
+
const normalizeText = value => String(value || '').toLowerCase();
|
|
4
|
+
|
|
5
|
+
const hasNegatedBreakfast = value => {
|
|
6
|
+
const text = normalizeText(value);
|
|
7
|
+
return /\b(?:no|without|excluding|exclude|excludes)\s+breakfast\b/.test(text)
|
|
8
|
+
|| /\b(?:no|without)\s+bed\s+(?:and|&)\s+breakfast\b/.test(text)
|
|
9
|
+
|| /\b(?:do|does)\s+not\s+(?:include|includes)\s+breakfast\b/.test(text)
|
|
10
|
+
|| /\bnot\s+including\s+breakfast\b/.test(text)
|
|
11
|
+
|| /\bbreakfast\b\s+(?:is\s+)?(?:not\s+included|not\s+inclusive|excluded)\b/.test(text);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const includesBreakfast = value => {
|
|
15
|
+
if (hasNegatedBreakfast(value)) return false;
|
|
16
|
+
const text = normalizeText(value);
|
|
17
|
+
return /\bbreakfast\b\s+(?:is\s+)?(?:included|inclusive)\b/.test(text)
|
|
18
|
+
|| /\b(?:including|includes|include|with)\s+breakfast\b/.test(text)
|
|
19
|
+
|| /\bbed\s+(?:and|&)\s+breakfast\b/.test(text);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getOptionInclusions = optionName => (
|
|
23
|
+
includesBreakfast(optionName) ? [BREAKFAST] : []
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const getExtraCategory = extraName => {
|
|
27
|
+
if (hasNegatedBreakfast(extraName)) return null;
|
|
28
|
+
return /\bbreakfast\b/.test(normalizeText(extraName)) ? BREAKFAST : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const addStructuredOptionMetadata = option => ({
|
|
32
|
+
...option,
|
|
33
|
+
inclusions: getOptionInclusions(option && option.optionName),
|
|
34
|
+
extras: (option && option.extras || []).map(extra => {
|
|
35
|
+
const category = getExtraCategory(extra && extra.name);
|
|
36
|
+
return category ? { ...extra, category } : extra;
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
addStructuredOptionMetadata,
|
|
42
|
+
getExtraCategory,
|
|
43
|
+
getOptionInclusions,
|
|
44
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const {
|
|
2
|
+
addStructuredOptionMetadata,
|
|
3
|
+
getExtraCategory,
|
|
4
|
+
getOptionInclusions,
|
|
5
|
+
} = require('./option-metadata');
|
|
6
|
+
|
|
7
|
+
describe('structured option metadata', () => {
|
|
8
|
+
test.each([
|
|
9
|
+
['Witherview Super King, Including Breakfast', ['breakfast']],
|
|
10
|
+
['Bed and Breakfast', ['breakfast']],
|
|
11
|
+
['Bed & Breakfast', ['breakfast']],
|
|
12
|
+
['Breakfast not included', []],
|
|
13
|
+
['No breakfast included', []],
|
|
14
|
+
['Does not include breakfast', []],
|
|
15
|
+
['Not including breakfast', []],
|
|
16
|
+
['No bed and breakfast', []],
|
|
17
|
+
['Room only', []],
|
|
18
|
+
])('normalizes option inclusion text %s', (optionName, expected) => {
|
|
19
|
+
expect(getOptionInclusions(optionName)).toEqual(expected);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('classifies selectable breakfast extras without changing unrelated extras', () => {
|
|
23
|
+
expect(addStructuredOptionMetadata({
|
|
24
|
+
optionName: 'Superior Room',
|
|
25
|
+
extras: [
|
|
26
|
+
{ id: '1', name: 'Breakfast' },
|
|
27
|
+
{ id: '2', name: 'Airport transfer' },
|
|
28
|
+
],
|
|
29
|
+
})).toEqual({
|
|
30
|
+
optionName: 'Superior Room',
|
|
31
|
+
inclusions: [],
|
|
32
|
+
extras: [
|
|
33
|
+
{ id: '1', name: 'Breakfast', category: 'breakfast' },
|
|
34
|
+
{ id: '2', name: 'Airport transfer' },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not classify negated breakfast text as a selectable category', () => {
|
|
40
|
+
expect(getExtraCategory('No breakfast')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|