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.
@@ -4155,6 +4155,7 @@ Object {
4155
4155
  "country": "United Kingdom",
4156
4156
  "currency": "GBP",
4157
4157
  "extras": Array [],
4158
+ "inclusions": Array [],
4158
4159
  "lastUpdateTimestamp": 1700750093,
4159
4160
  "optRates": Object {
4160
4161
  "CancelPolicies": Object {
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 {
@@ -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
- callTourplan,
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 (normalizedBookingReferenceIds.length) {
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
- ? searchCriterias.map(keyObj => ({
235
- keyObj,
236
- promise: (async () => {
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
- // Date-range-only search (no explicit criteria). Wrap in the same try/catch
280
- // pattern as the map branch so a non-HTTP error is treated as zero results
281
- // rather than crashing the entire search.
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
 
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.134",
3
+ "version": "1.0.136",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {