ti2-tourplan 1.0.131 → 1.0.133
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/availability/itinerary-availability-helper.js +8 -1
- package/availability/itinerary-availability.js +22 -3
- package/availability/itinerary-availability.pickup-points.test.js +219 -0
- package/availability/itinerary-availability.validation-flags.test.js +122 -0
- package/index.test.js +46 -0
- package/itinerary-products-search.js +11 -28
- package/itinerary-search.js +50 -26
- package/package.json +1 -1
- package/tp-helpers/pickup-points.js +21 -0
- package/tp-helpers/pickup-points.test.js +51 -0
- package/utils.js +29 -0
- package/utils.test.js +28 -1
|
@@ -18,6 +18,8 @@ const {
|
|
|
18
18
|
const {
|
|
19
19
|
CROSS_SEASON_CAL_USING_RATE_OF_FIRST_RATE_PERIOD,
|
|
20
20
|
} = require('./product-connect/itinerary-pc-option-helper');
|
|
21
|
+
const { isEnabled, normalizePickupPoints } = require('../tp-helpers/pickup-points');
|
|
22
|
+
const { getOptionInfoTag } = require('../utils');
|
|
21
23
|
|
|
22
24
|
const NO_RATE_FOUND_FOR_LAST_YEAR_ERROR_MESSAGE = 'Custom rates cannot be calculated as the previous year\'s rate could not be found. Please change the date and try again.';
|
|
23
25
|
const NO_RATE_FOUND_FOR_IMMEDIATE_LAST_DATE_RANGE_ERROR_MESSAGE = 'Custom rates cannot be calculated no last rates available. Please change the date and try again.';
|
|
@@ -131,6 +133,7 @@ const GENERIC_AVALABILITY_CHK_ERROR_MESSAGE = 'Not bookable for the requested da
|
|
|
131
133
|
+ '(e.g. no rates, block out period, on request, minimum stay etc.).';
|
|
132
134
|
const RATES_AVAILABLE_TILL_ERROR_TEMPLATE = ' Rates are only available until {dateTill}. '
|
|
133
135
|
+ 'Please change the date and try again.';
|
|
136
|
+
|
|
134
137
|
/**
|
|
135
138
|
* Calculate days in a year for a given date, accounting for leap years
|
|
136
139
|
* @param {string} dateString - Date string in YYYY-MM-DD format
|
|
@@ -163,6 +166,7 @@ const getAvailabilityOnly = async ({
|
|
|
163
166
|
axios,
|
|
164
167
|
startDate,
|
|
165
168
|
requestedEndDate,
|
|
169
|
+
pickupPointsRequired,
|
|
166
170
|
callTourplan,
|
|
167
171
|
}) => {
|
|
168
172
|
const isValidRequestedEndDate = moment(requestedEndDate, 'YYYY-MM-DD', true).isValid();
|
|
@@ -172,7 +176,7 @@ const getAvailabilityOnly = async ({
|
|
|
172
176
|
const model = {
|
|
173
177
|
OptionInfoRequest: {
|
|
174
178
|
Opt: optionId,
|
|
175
|
-
Info: 'GAR',
|
|
179
|
+
Info: getOptionInfoTag({ checkType: 'GAR', pickupPointsRequired }),
|
|
176
180
|
AgentID: hostConnectAgentID,
|
|
177
181
|
Password: hostConnectAgentPassword,
|
|
178
182
|
DateFrom: startDate,
|
|
@@ -187,9 +191,12 @@ const getAvailabilityOnly = async ({
|
|
|
187
191
|
xmlOptions: hostConnectXmlOptions,
|
|
188
192
|
});
|
|
189
193
|
const option = R.path(['OptionInfoReply', 'Option'], replyObj);
|
|
194
|
+
const pickupPoints = normalizePickupPoints(R.path(['OptGeneral', 'PickupPoints'], option))
|
|
195
|
+
|| normalizePickupPoints(R.path(['PickupPoints'], option));
|
|
190
196
|
return {
|
|
191
197
|
optAvail: R.pathOr([], ['OptAvail'], option),
|
|
192
198
|
optRates: R.path(['OptRates'], option),
|
|
199
|
+
...(isEnabled(pickupPointsRequired) && pickupPoints ? { pickupPoints } : {}),
|
|
193
200
|
};
|
|
194
201
|
};
|
|
195
202
|
/*
|
|
@@ -43,6 +43,8 @@ const {
|
|
|
43
43
|
CROSS_SEASON_CAL_USING_RATE_OF_FIRST_RATE_PERIOD,
|
|
44
44
|
} = require('./product-connect/itinerary-pc-option-helper');
|
|
45
45
|
|
|
46
|
+
const { isEnabled } = require('../tp-helpers/pickup-points');
|
|
47
|
+
|
|
46
48
|
const MIN_STAY_WARNING_MESSAGE = 'Please note that the a minimum stay requirement of {minSCU} was required for {customPeriodInfoMsg}';
|
|
47
49
|
const PREVIOUS_RATE_CLOSED_PERIODS_ERROR_MESSAGE = 'Not bookable for the requested date/stay using {customPeriodInfoMsg} {closedDateRanges}';
|
|
48
50
|
const INVALID_DAY_OF_WEEK_ERROR_TEMPLATE = 'The start date day can only be on {allowedDays}. Please try again with the allowed day.';
|
|
@@ -66,7 +68,19 @@ const doAllDatesHaveRatesAvailable = (lastDateRangeEndDate, startDate, chargeUni
|
|
|
66
68
|
return !atLeastOneDateDoesNotHaveRatesAvailable;
|
|
67
69
|
};
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
// getAvailabilityOnly already normalizes via tp-helpers/pickup-points, so pickupPoints
|
|
72
|
+
// here (when present) is always a non-empty PickupPoint array; just check for it.
|
|
73
|
+
const attachPickupPoints = (response, pickupPoints) => (
|
|
74
|
+
pickupPoints && Array.isArray(pickupPoints.PickupPoint) && pickupPoints.PickupPoint.length
|
|
75
|
+
? { ...response, pickupPoints }
|
|
76
|
+
: response
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const returnEmptyRatesOrError = (
|
|
80
|
+
allowSendingServicesWithoutARate,
|
|
81
|
+
agentCurrencyCode,
|
|
82
|
+
errorMsg,
|
|
83
|
+
) => {
|
|
70
84
|
// if sending services without a rate is enabled return empty rates (0.00)
|
|
71
85
|
if (allowSendingServicesWithoutARate && agentCurrencyCode) {
|
|
72
86
|
return {
|
|
@@ -145,6 +159,7 @@ const searchAvailabilityForItinerary = async ({
|
|
|
145
159
|
chargeUnitQuantity,
|
|
146
160
|
roomTypeRequired = true,
|
|
147
161
|
availabilityOnly = false,
|
|
162
|
+
pickupPointsRequired = false,
|
|
148
163
|
},
|
|
149
164
|
callTourplan,
|
|
150
165
|
cache,
|
|
@@ -152,7 +167,7 @@ const searchAvailabilityForItinerary = async ({
|
|
|
152
167
|
const isAvailabilityOnly = availabilityOnly === true;
|
|
153
168
|
|
|
154
169
|
if (isAvailabilityOnly) {
|
|
155
|
-
const { optAvail, optRates } = await getAvailabilityOnly({
|
|
170
|
+
const { optAvail, optRates, pickupPoints } = await getAvailabilityOnly({
|
|
156
171
|
optionId,
|
|
157
172
|
hostConnectEndpoint,
|
|
158
173
|
hostConnectAgentID,
|
|
@@ -160,12 +175,13 @@ const searchAvailabilityForItinerary = async ({
|
|
|
160
175
|
axios,
|
|
161
176
|
startDate,
|
|
162
177
|
requestedEndDate,
|
|
178
|
+
pickupPointsRequired,
|
|
163
179
|
callTourplan,
|
|
164
180
|
});
|
|
165
181
|
const optAvailValues = getOptAvailValues(optAvail);
|
|
166
182
|
const SCheckPass = isOptAvailBookable(optAvailValues);
|
|
167
183
|
const rates = (SCheckPass && optRates) ? [optRates] : [];
|
|
168
|
-
|
|
184
|
+
const availabilityResponse = {
|
|
169
185
|
bookable: SCheckPass,
|
|
170
186
|
type: 'availability',
|
|
171
187
|
...(requestedEndDate && SCheckPass ? { endDate: requestedEndDate } : {}),
|
|
@@ -175,6 +191,9 @@ const searchAvailabilityForItinerary = async ({
|
|
|
175
191
|
availability: optAvailValues,
|
|
176
192
|
rates,
|
|
177
193
|
};
|
|
194
|
+
return isEnabled(pickupPointsRequired)
|
|
195
|
+
? attachPickupPoints(availabilityResponse, pickupPoints)
|
|
196
|
+
: availabilityResponse;
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
// Get agent currency code & cache it
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/* globals describe, it, expect, jest */
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
getAvailabilityOnly,
|
|
5
|
+
getStayResults,
|
|
6
|
+
} = require('./itinerary-availability-helper');
|
|
7
|
+
const { searchAvailabilityForItinerary } = require('./itinerary-availability');
|
|
8
|
+
|
|
9
|
+
describe('availability pickup points', () => {
|
|
10
|
+
const baseParams = {
|
|
11
|
+
optionId: 'LONSMPICKUP00001',
|
|
12
|
+
hostConnectEndpoint: 'https://test-host-connect.com',
|
|
13
|
+
hostConnectAgentID: 'test-agent-id',
|
|
14
|
+
hostConnectAgentPassword: 'test-agent-password',
|
|
15
|
+
axios: jest.fn(),
|
|
16
|
+
startDate: '2025-04-01',
|
|
17
|
+
requestedEndDate: '2025-04-05',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const singlePickupPoint = {
|
|
21
|
+
Point_ID: '504',
|
|
22
|
+
PointDescription: 'Keio Plaza Hotel Tokyo',
|
|
23
|
+
CanPickup: 'Y',
|
|
24
|
+
CanDropoff: 'Y',
|
|
25
|
+
puTime: '0815',
|
|
26
|
+
doTime: '1800',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const optionInfoReply = ({ pickupPoints, optAvail = '-2 -2 -2' } = {}) => ({
|
|
30
|
+
OptionInfoReply: {
|
|
31
|
+
Option: {
|
|
32
|
+
OptAvail: optAvail,
|
|
33
|
+
OptRates: { Currency: 'USD' },
|
|
34
|
+
OptGeneral: {
|
|
35
|
+
...(pickupPoints !== undefined ? { PickupPoints: pickupPoints } : {}),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps GAR OptionInfo by default', async () => {
|
|
42
|
+
const callTourplan = jest.fn(async () => optionInfoReply());
|
|
43
|
+
|
|
44
|
+
await getAvailabilityOnly({
|
|
45
|
+
...baseParams,
|
|
46
|
+
callTourplan,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
|
|
50
|
+
model: expect.objectContaining({
|
|
51
|
+
OptionInfoRequest: expect.objectContaining({
|
|
52
|
+
Info: 'GAR',
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each([false, 'false', 0, null, undefined])(
|
|
59
|
+
'keeps GAR OptionInfo when pickupPointsRequired is %p',
|
|
60
|
+
async pickupPointsRequired => {
|
|
61
|
+
const callTourplan = jest.fn(async () => optionInfoReply());
|
|
62
|
+
|
|
63
|
+
await getAvailabilityOnly({
|
|
64
|
+
...baseParams,
|
|
65
|
+
pickupPointsRequired,
|
|
66
|
+
callTourplan,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
|
|
70
|
+
model: expect.objectContaining({
|
|
71
|
+
OptionInfoRequest: expect.objectContaining({
|
|
72
|
+
Info: 'GAR',
|
|
73
|
+
}),
|
|
74
|
+
}),
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
it.each([true, 1, 'true', 'yes', 'YES'])(
|
|
80
|
+
'adds P to OptionInfo when pickupPointsRequired is %p',
|
|
81
|
+
async pickupPointsRequired => {
|
|
82
|
+
const callTourplan = jest.fn(async () => optionInfoReply({
|
|
83
|
+
pickupPoints: { PickupPoint: singlePickupPoint },
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const result = await getAvailabilityOnly({
|
|
87
|
+
...baseParams,
|
|
88
|
+
pickupPointsRequired,
|
|
89
|
+
callTourplan,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
|
|
93
|
+
model: expect.objectContaining({
|
|
94
|
+
OptionInfoRequest: expect.objectContaining({
|
|
95
|
+
Info: 'GARP',
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
}));
|
|
99
|
+
expect(result.pickupPoints).toEqual({
|
|
100
|
+
PickupPoint: [singlePickupPoint],
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
it('omits pickupPoints when Tourplan returns none', async () => {
|
|
106
|
+
const callTourplan = jest.fn(async () => optionInfoReply({
|
|
107
|
+
pickupPoints: { PickupPoint: [] },
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
const result = await getAvailabilityOnly({
|
|
111
|
+
...baseParams,
|
|
112
|
+
pickupPointsRequired: true,
|
|
113
|
+
callTourplan,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result).not.toHaveProperty('pickupPoints');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('omits pickupPoints when pickupPointsRequired is disabled', async () => {
|
|
120
|
+
const callTourplan = jest.fn(async () => optionInfoReply({
|
|
121
|
+
pickupPoints: { PickupPoint: singlePickupPoint },
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
const result = await getAvailabilityOnly({
|
|
125
|
+
...baseParams,
|
|
126
|
+
pickupPointsRequired: false,
|
|
127
|
+
callTourplan,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(result).not.toHaveProperty('pickupPoints');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to top-level pickup points when OptGeneral pickup points are empty', async () => {
|
|
134
|
+
const callTourplan = jest.fn(async () => ({
|
|
135
|
+
OptionInfoReply: {
|
|
136
|
+
Option: {
|
|
137
|
+
OptAvail: '-2 -2 -2',
|
|
138
|
+
OptRates: { Currency: 'USD' },
|
|
139
|
+
OptGeneral: {
|
|
140
|
+
PickupPoints: { PickupPoint: [] },
|
|
141
|
+
},
|
|
142
|
+
PickupPoints: { PickupPoint: singlePickupPoint },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const result = await getAvailabilityOnly({
|
|
148
|
+
...baseParams,
|
|
149
|
+
pickupPointsRequired: true,
|
|
150
|
+
callTourplan,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.pickupPoints).toEqual({
|
|
154
|
+
PickupPoint: [singlePickupPoint],
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps GS OptionInfo for stay results (pickup points come from GD/GAR)', async () => {
|
|
159
|
+
const callTourplan = jest.fn(async () => ({
|
|
160
|
+
OptionInfoReply: {
|
|
161
|
+
Option: {
|
|
162
|
+
OptStayResults: [],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
await getStayResults(
|
|
168
|
+
baseParams.optionId,
|
|
169
|
+
baseParams.hostConnectEndpoint,
|
|
170
|
+
baseParams.hostConnectAgentID,
|
|
171
|
+
baseParams.hostConnectAgentPassword,
|
|
172
|
+
baseParams.axios,
|
|
173
|
+
baseParams.startDate,
|
|
174
|
+
1,
|
|
175
|
+
[{ Adults: 2 }],
|
|
176
|
+
undefined,
|
|
177
|
+
callTourplan,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
|
|
181
|
+
model: expect.objectContaining({
|
|
182
|
+
OptionInfoRequest: expect.objectContaining({
|
|
183
|
+
Info: 'GS',
|
|
184
|
+
}),
|
|
185
|
+
}),
|
|
186
|
+
}));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('returns pickupPoints on the availabilityOnly search response', async () => {
|
|
190
|
+
const callTourplan = jest.fn(async () => optionInfoReply({
|
|
191
|
+
pickupPoints: { PickupPoint: singlePickupPoint },
|
|
192
|
+
optAvail: '-2 -2 -2',
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
const result = await searchAvailabilityForItinerary({
|
|
196
|
+
axios: jest.fn(),
|
|
197
|
+
token: {
|
|
198
|
+
hostConnectEndpoint: baseParams.hostConnectEndpoint,
|
|
199
|
+
hostConnectAgentID: baseParams.hostConnectAgentID,
|
|
200
|
+
hostConnectAgentPassword: baseParams.hostConnectAgentPassword,
|
|
201
|
+
},
|
|
202
|
+
payload: {
|
|
203
|
+
optionId: baseParams.optionId,
|
|
204
|
+
startDate: baseParams.startDate,
|
|
205
|
+
endDate: baseParams.requestedEndDate,
|
|
206
|
+
availabilityOnly: true,
|
|
207
|
+
pickupPointsRequired: 'true',
|
|
208
|
+
paxConfigs: [{ adults: 2 }],
|
|
209
|
+
},
|
|
210
|
+
callTourplan,
|
|
211
|
+
cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(result.bookable).toBe(true);
|
|
215
|
+
expect(result.pickupPoints).toEqual({
|
|
216
|
+
PickupPoint: [singlePickupPoint],
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -183,6 +183,128 @@ describe('searchAvailabilityForItinerary validation flags', () => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
it('passes pickupPointsRequired through the availabilityOnly flow', async () => {
|
|
187
|
+
await searchAvailabilityForItinerary({
|
|
188
|
+
axios: jest.fn(),
|
|
189
|
+
token: baseToken,
|
|
190
|
+
payload: {
|
|
191
|
+
...basePayload,
|
|
192
|
+
availabilityOnly: true,
|
|
193
|
+
endDate: '2025-04-05',
|
|
194
|
+
pickupPointsRequired: true,
|
|
195
|
+
},
|
|
196
|
+
callTourplan: jest.fn(),
|
|
197
|
+
cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(itineraryAvailabilityHelper.getAvailabilityOnly).toHaveBeenCalledWith(
|
|
201
|
+
expect.objectContaining({ pickupPointsRequired: true }),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not pass pickupPointsRequired through the rates availability flow', async () => {
|
|
206
|
+
const result = await searchAvailabilityForItinerary({
|
|
207
|
+
axios: jest.fn(),
|
|
208
|
+
token: baseToken,
|
|
209
|
+
payload: { ...basePayload, pickupPointsRequired: true },
|
|
210
|
+
callTourplan: jest.fn(),
|
|
211
|
+
cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(itineraryAvailabilityHelper.getAvailabilityConfig).toHaveBeenCalledWith(
|
|
215
|
+
expect.not.objectContaining({ pickupPointsRequired: expect.anything() }),
|
|
216
|
+
);
|
|
217
|
+
expect(itineraryAvailabilityHelper.getStayResults).toHaveBeenCalledWith(
|
|
218
|
+
basePayload.optionId,
|
|
219
|
+
baseToken.hostConnectEndpoint,
|
|
220
|
+
baseToken.hostConnectAgentID,
|
|
221
|
+
baseToken.hostConnectAgentPassword,
|
|
222
|
+
expect.any(Function),
|
|
223
|
+
basePayload.startDate,
|
|
224
|
+
basePayload.chargeUnitQuantity,
|
|
225
|
+
expect.any(Array),
|
|
226
|
+
undefined,
|
|
227
|
+
expect.any(Function),
|
|
228
|
+
);
|
|
229
|
+
expect(result).not.toHaveProperty('pickupPoints');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns pickupPoints on the availabilityOnly response when provided', async () => {
|
|
233
|
+
const pickupPoints = {
|
|
234
|
+
PickupPoint: [{ Point_ID: '504', PointDescription: 'Keio Plaza Hotel Tokyo' }],
|
|
235
|
+
};
|
|
236
|
+
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
|
|
237
|
+
optAvail: '-2 -2 -2',
|
|
238
|
+
optRates: { Currency: 'USD' },
|
|
239
|
+
pickupPoints,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await searchAvailabilityForItinerary({
|
|
243
|
+
axios: jest.fn(),
|
|
244
|
+
token: baseToken,
|
|
245
|
+
payload: {
|
|
246
|
+
...basePayload,
|
|
247
|
+
availabilityOnly: true,
|
|
248
|
+
endDate: '2025-04-05',
|
|
249
|
+
pickupPointsRequired: 'yes',
|
|
250
|
+
},
|
|
251
|
+
callTourplan: jest.fn(),
|
|
252
|
+
cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(result.pickupPoints).toEqual(pickupPoints);
|
|
256
|
+
expect(result.bookable).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('omits pickupPoints on the availabilityOnly response when pickupPointsRequired is disabled', async () => {
|
|
260
|
+
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
|
|
261
|
+
optAvail: '-2 -2 -2',
|
|
262
|
+
optRates: { Currency: 'USD' },
|
|
263
|
+
pickupPoints: {
|
|
264
|
+
PickupPoint: [{ Point_ID: '504', PointDescription: 'Keio Plaza Hotel Tokyo' }],
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const result = await searchAvailabilityForItinerary({
|
|
269
|
+
axios: jest.fn(),
|
|
270
|
+
token: baseToken,
|
|
271
|
+
payload: {
|
|
272
|
+
...basePayload,
|
|
273
|
+
availabilityOnly: true,
|
|
274
|
+
endDate: '2025-04-05',
|
|
275
|
+
pickupPointsRequired: false,
|
|
276
|
+
},
|
|
277
|
+
callTourplan: jest.fn(),
|
|
278
|
+
cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(result).not.toHaveProperty('pickupPoints');
|
|
282
|
+
expect(result.bookable).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('omits pickupPoints when the helper returns an empty PickupPoint array', async () => {
|
|
286
|
+
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
|
|
287
|
+
optAvail: '-2 -2 -2',
|
|
288
|
+
optRates: { Currency: 'USD' },
|
|
289
|
+
pickupPoints: { PickupPoint: [] },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const result = await searchAvailabilityForItinerary({
|
|
293
|
+
axios: jest.fn(),
|
|
294
|
+
token: baseToken,
|
|
295
|
+
payload: {
|
|
296
|
+
...basePayload,
|
|
297
|
+
availabilityOnly: true,
|
|
298
|
+
endDate: '2025-04-05',
|
|
299
|
+
pickupPointsRequired: true,
|
|
300
|
+
},
|
|
301
|
+
callTourplan: jest.fn(),
|
|
302
|
+
cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(result).not.toHaveProperty('pickupPoints');
|
|
306
|
+
});
|
|
307
|
+
|
|
186
308
|
it('marks availabilityOnly response as not bookable when all days are unavailable', async () => {
|
|
187
309
|
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
|
|
188
310
|
optAvail: '-1 -1 -1',
|
package/index.test.js
CHANGED
|
@@ -1804,6 +1804,52 @@ describe('search tests', () => {
|
|
|
1804
1804
|
expect(retVal.bookings[0].bookingId).toBe('316559');
|
|
1805
1805
|
});
|
|
1806
1806
|
|
|
1807
|
+
it('searchItineraries keeps successful fallback criteria when one bookingId criterion fails', async () => {
|
|
1808
|
+
axios.mockImplementation(getFixture);
|
|
1809
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1810
|
+
mockCallTourplan.mockImplementation(async request => {
|
|
1811
|
+
const { model } = request;
|
|
1812
|
+
const listRequest = model && model.ListBookingsRequest;
|
|
1813
|
+
if (listRequest && listRequest.BookingId === '2694192') {
|
|
1814
|
+
throw new Error('Request failed with status code 500: Object not found in BookingData cache 2694192');
|
|
1815
|
+
}
|
|
1816
|
+
if (listRequest && listRequest.Ref === '2694192') {
|
|
1817
|
+
return { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
|
|
1818
|
+
}
|
|
1819
|
+
if (listRequest && listRequest.AgentRef === '2694192') {
|
|
1820
|
+
return {
|
|
1821
|
+
ListBookingsReply: {
|
|
1822
|
+
BookingHeaders: {
|
|
1823
|
+
BookingHeader: [{ BookingId: '316559' }],
|
|
1824
|
+
},
|
|
1825
|
+
},
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
return defaultCallTourplanImplementation(request);
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
try {
|
|
1832
|
+
const retVal = await app.searchItineraries({
|
|
1833
|
+
axios,
|
|
1834
|
+
token,
|
|
1835
|
+
typeDefsAndQueries,
|
|
1836
|
+
payload: {
|
|
1837
|
+
bookingId: '2694192',
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
expect(retVal.bookings.length).toBe(1);
|
|
1842
|
+
expect(retVal.bookings[0].bookingId).toBe('316559');
|
|
1843
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1844
|
+
'[tourplan] ListBookingsRequest failed',
|
|
1845
|
+
{ BookingId: '2694192' },
|
|
1846
|
+
'Request failed with status code 500: Object not found in BookingData cache 2694192',
|
|
1847
|
+
);
|
|
1848
|
+
} finally {
|
|
1849
|
+
warnSpy.mockRestore();
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1807
1853
|
it('searchItineraries by name (NameContains)', async () => {
|
|
1808
1854
|
axios.mockImplementation(getFixture);
|
|
1809
1855
|
const retVal = await app.searchItineraries({
|
|
@@ -7,30 +7,11 @@ 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 {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// Keep Tourplan PascalCase under camelCase pickupPoints (same pattern as optRates).
|
|
19
|
-
// Bokun channel-manager reads container.PickupPoint / point.Point_ID etc.
|
|
20
|
-
const normalizePickupPoints = rawPickupPoints => {
|
|
21
|
-
if (!rawPickupPoints || typeof rawPickupPoints !== 'object') return undefined;
|
|
22
|
-
const points = asArray(R.path(['PickupPoint'], rawPickupPoints))
|
|
23
|
-
.filter(point => point && typeof point === 'object' && !Array.isArray(point));
|
|
24
|
-
if (!points.length) return undefined;
|
|
25
|
-
return {
|
|
26
|
-
...rawPickupPoints,
|
|
27
|
-
PickupPoint: points,
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const getOptionInfoTag = ({ includeRates = false, pickupPointsRequired = false } = {}) => (
|
|
32
|
-
`${includeRates ? 'GR' : 'G'}${pickupPointsRequired ? 'P' : ''}`
|
|
33
|
-
);
|
|
10
|
+
const {
|
|
11
|
+
isEnabled,
|
|
12
|
+
normalizePickupPoints,
|
|
13
|
+
} = require('./tp-helpers/pickup-points');
|
|
14
|
+
const { getOptionInfoTag } = require('./utils');
|
|
34
15
|
|
|
35
16
|
const searchProductsForItinerary = async ({
|
|
36
17
|
axios,
|
|
@@ -273,10 +254,12 @@ const searchProductsForItinerary = async ({
|
|
|
273
254
|
|| R.path(['__destination', 'city'], rawOption);
|
|
274
255
|
const country = currentOption.country
|
|
275
256
|
|| R.path(['__destination', 'country'], rawOption);
|
|
276
|
-
const pickupPoints =
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
257
|
+
const pickupPoints = shouldRequestPickupPoints
|
|
258
|
+
? normalizePickupPoints(
|
|
259
|
+
R.path(['OptGeneral', 'PickupPoints'], rawOption)
|
|
260
|
+
|| R.path(['PickupPoints'], rawOption),
|
|
261
|
+
)
|
|
262
|
+
: undefined;
|
|
280
263
|
return {
|
|
281
264
|
...R.omit(['city', 'country', 'rateContext'], currentOption),
|
|
282
265
|
...(city ? { city } : {}),
|
package/itinerary-search.js
CHANGED
|
@@ -231,13 +231,15 @@ const searchItineraries = async ({
|
|
|
231
231
|
|
|
232
232
|
// Step4: Fetch for bookings based on the search criterias.
|
|
233
233
|
const allSearches = searchCriterias.length
|
|
234
|
-
? searchCriterias.map(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
reply
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
? searchCriterias.map(keyObj => ({
|
|
235
|
+
keyObj,
|
|
236
|
+
promise: (async () => {
|
|
237
|
+
let reply;
|
|
238
|
+
try {
|
|
239
|
+
reply = await callTourplan(getPayload('ListBookingsRequest', {
|
|
240
|
+
...(baseSearchFilters ? { ...baseSearchFilters } : {}),
|
|
241
|
+
...keyObj,
|
|
242
|
+
}));
|
|
241
243
|
/*
|
|
242
244
|
<Reply>
|
|
243
245
|
<ListBookingsReply>
|
|
@@ -262,33 +264,55 @@ const searchItineraries = async ({
|
|
|
262
264
|
</ListBookingsReply>
|
|
263
265
|
</Reply>
|
|
264
266
|
*/
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
|
|
269
|
+
if (errMsg.includes('Request failed with status code')) {
|
|
270
|
+
throw Error(errMsg);
|
|
271
|
+
}
|
|
272
|
+
// if it's not server error, we just considered as no booking is found
|
|
273
|
+
reply = { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
|
|
269
274
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return reply;
|
|
274
|
-
})
|
|
275
|
+
return reply;
|
|
276
|
+
})(),
|
|
277
|
+
}))
|
|
275
278
|
: [
|
|
276
279
|
// Date-range-only search (no explicit criteria). Wrap in the same try/catch
|
|
277
280
|
// pattern as the map branch so a non-HTTP error is treated as zero results
|
|
278
281
|
// rather than crashing the entire search.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
},
|
|
288
294
|
];
|
|
289
295
|
|
|
290
296
|
// Step5: Get full booking details for each booking.
|
|
291
|
-
const
|
|
297
|
+
const settledSearches = await global.Promise.allSettled(allSearches.map(({ promise }) => promise));
|
|
298
|
+
const replyObjs = [];
|
|
299
|
+
const rejectedSearches = [];
|
|
300
|
+
|
|
301
|
+
settledSearches.forEach((result, idx) => {
|
|
302
|
+
if (result.status === 'fulfilled') {
|
|
303
|
+
replyObjs.push(result.value);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const { keyObj } = allSearches[idx];
|
|
307
|
+
const { reason } = result;
|
|
308
|
+
const errMsg = reason instanceof Error ? reason.message : String(reason);
|
|
309
|
+
rejectedSearches.push(reason);
|
|
310
|
+
console.warn('[tourplan] ListBookingsRequest failed', keyObj, errMsg);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (!replyObjs.length && rejectedSearches.length) {
|
|
314
|
+
throw rejectedSearches[0];
|
|
315
|
+
}
|
|
292
316
|
const bookingHeadersRaw = R.flatten(
|
|
293
317
|
replyObjs.map(o => R.pathOr(
|
|
294
318
|
[],
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const R = require('ramda');
|
|
2
|
+
const { asArray } = require('./values');
|
|
3
|
+
const { isEnabled } = require('../utils');
|
|
4
|
+
|
|
5
|
+
// Keep Tourplan PascalCase under camelCase pickupPoints (same pattern as optRates).
|
|
6
|
+
// Bokun channel-manager reads container.PickupPoint / point.Point_ID etc.
|
|
7
|
+
const normalizePickupPoints = rawPickupPoints => {
|
|
8
|
+
if (!rawPickupPoints || typeof rawPickupPoints !== 'object') return undefined;
|
|
9
|
+
const points = asArray(R.path(['PickupPoint'], rawPickupPoints))
|
|
10
|
+
.filter(point => point && typeof point === 'object' && !Array.isArray(point));
|
|
11
|
+
if (!points.length) return undefined;
|
|
12
|
+
return {
|
|
13
|
+
...rawPickupPoints,
|
|
14
|
+
PickupPoint: points,
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
isEnabled,
|
|
20
|
+
normalizePickupPoints,
|
|
21
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* globals describe, it, expect */
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
isEnabled,
|
|
5
|
+
normalizePickupPoints,
|
|
6
|
+
} = require('./pickup-points');
|
|
7
|
+
|
|
8
|
+
describe('pickup-points helpers', () => {
|
|
9
|
+
describe('isEnabled', () => {
|
|
10
|
+
it.each([true, 1, 'true', 'TRUE', '1', 'yes', ' Yes '])(
|
|
11
|
+
'treats %p as enabled',
|
|
12
|
+
value => {
|
|
13
|
+
expect(isEnabled(value)).toBe(true);
|
|
14
|
+
},
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
it.each([false, 0, 'false', 'no', '', null, undefined, 'maybe'])(
|
|
18
|
+
'treats %p as disabled',
|
|
19
|
+
value => {
|
|
20
|
+
expect(isEnabled(value)).toBe(false);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('normalizePickupPoints', () => {
|
|
26
|
+
const singlePickupPoint = {
|
|
27
|
+
Point_ID: '504',
|
|
28
|
+
PointDescription: 'Keio Plaza Hotel Tokyo',
|
|
29
|
+
CanPickup: 'Y',
|
|
30
|
+
CanDropoff: 'Y',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
it('normalizes a single PickupPoint object to an array', () => {
|
|
34
|
+
expect(normalizePickupPoints({ PickupPoint: singlePickupPoint })).toEqual({
|
|
35
|
+
PickupPoint: [singlePickupPoint],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps already-array PickupPoint values', () => {
|
|
40
|
+
const pickupPoints = { PickupPoint: [singlePickupPoint] };
|
|
41
|
+
expect(normalizePickupPoints(pickupPoints)).toEqual(pickupPoints);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('omits empty shells and non-objects', () => {
|
|
45
|
+
expect(normalizePickupPoints(undefined)).toBeUndefined();
|
|
46
|
+
expect(normalizePickupPoints({})).toBeUndefined();
|
|
47
|
+
expect(normalizePickupPoints({ PickupPoint: [] })).toBeUndefined();
|
|
48
|
+
expect(normalizePickupPoints('nope')).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
package/utils.js
CHANGED
|
@@ -71,6 +71,33 @@ const escapeInvalidXmlChars = str => {
|
|
|
71
71
|
.replace(BAD_XML_CHARS, '');
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
const isEnabled = value => {
|
|
75
|
+
if (value === true || value === 1) return true;
|
|
76
|
+
const normalized = String(value == null ? '' : value).trim().toLowerCase();
|
|
77
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a HostConnect OptionInfoRequest.Info tag (e.g. G, GR, GAR, GD, + optional P).
|
|
82
|
+
*
|
|
83
|
+
* @param {object} [options]
|
|
84
|
+
* @param {string} [options.checkType] - Explicit base tag (e.g. 'GAR', 'GD', 'GS').
|
|
85
|
+
* When provided, this takes precedence and `includeRates` is ignored.
|
|
86
|
+
* @param {boolean} [options.includeRates=false] - Only used when `checkType` is omitted;
|
|
87
|
+
* selects between the product-search base tags 'GR' (rates included) and 'G'.
|
|
88
|
+
* @param {boolean|string|number} [options.pickupPointsRequired=false] - When enabled
|
|
89
|
+
* (see `isEnabled`), appends 'P' to the base tag to request pickup/dropoff points.
|
|
90
|
+
* @returns {string} The resulting Info tag, e.g. 'G', 'GR', 'GARP'.
|
|
91
|
+
*/
|
|
92
|
+
const getOptionInfoTag = ({
|
|
93
|
+
checkType,
|
|
94
|
+
includeRates = false,
|
|
95
|
+
pickupPointsRequired = false,
|
|
96
|
+
} = {}) => {
|
|
97
|
+
const base = checkType || (includeRates ? 'GR' : 'G');
|
|
98
|
+
return `${base}${isEnabled(pickupPointsRequired) ? 'P' : ''}`;
|
|
99
|
+
};
|
|
100
|
+
|
|
74
101
|
const getValidDateOfBirth = dob => {
|
|
75
102
|
if (typeof dob !== 'string') return undefined;
|
|
76
103
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dob);
|
|
@@ -188,4 +215,6 @@ module.exports = {
|
|
|
188
215
|
hostConnectXmlOptions,
|
|
189
216
|
productConnectXmlOptions,
|
|
190
217
|
passengerTypeMap,
|
|
218
|
+
isEnabled,
|
|
219
|
+
getOptionInfoTag,
|
|
191
220
|
};
|
package/utils.test.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/* globals describe, it, expect */
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
escapeInvalidXmlChars,
|
|
5
|
+
getOptionInfoTag,
|
|
6
|
+
getRoomConfigs,
|
|
7
|
+
} = require('./utils');
|
|
2
8
|
|
|
3
9
|
describe('getRoomConfigs', () => {
|
|
4
10
|
const getPaxDetails = passenger => getRoomConfigs([{
|
|
@@ -135,3 +141,24 @@ describe('escapeInvalidXmlChars', () => {
|
|
|
135
141
|
expect(escapeInvalidXmlChars('X\uD800Y')).toBe('XY');
|
|
136
142
|
});
|
|
137
143
|
});
|
|
144
|
+
|
|
145
|
+
describe('getOptionInfoTag', () => {
|
|
146
|
+
it('uses includeRates for product-search tags when checkType is omitted', () => {
|
|
147
|
+
expect(getOptionInfoTag()).toBe('G');
|
|
148
|
+
expect(getOptionInfoTag({ includeRates: true })).toBe('GR');
|
|
149
|
+
expect(getOptionInfoTag({ includeRates: false, pickupPointsRequired: 'false' })).toBe('G');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('keeps an explicit checkType when pickup points are not required', () => {
|
|
153
|
+
expect(getOptionInfoTag({ checkType: 'GAR' })).toBe('GAR');
|
|
154
|
+
expect(getOptionInfoTag({ checkType: 'GD', pickupPointsRequired: false })).toBe('GD');
|
|
155
|
+
expect(getOptionInfoTag({ includeRates: true, pickupPointsRequired: 'false' })).toBe('GR');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('appends P for truthy pickupPointsRequired values', () => {
|
|
159
|
+
expect(getOptionInfoTag({ checkType: 'GAR', pickupPointsRequired: true })).toBe('GARP');
|
|
160
|
+
expect(getOptionInfoTag({ checkType: 'GD', pickupPointsRequired: 'yes' })).toBe('GDP');
|
|
161
|
+
expect(getOptionInfoTag({ includeRates: true, pickupPointsRequired: 1 })).toBe('GRP');
|
|
162
|
+
expect(getOptionInfoTag({ pickupPointsRequired: true })).toBe('GP');
|
|
163
|
+
});
|
|
164
|
+
});
|