ti2-tourplan 1.0.128 → 1.0.129
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/.github/workflows/nodejs.yml +8 -7
- package/index.test.js +335 -50
- package/itinerary-products-search.js +21 -6
- package/package.json +1 -1
- package/utils.js +23 -2
- package/utils.test.js +102 -1
|
@@ -22,12 +22,17 @@ jobs:
|
|
|
22
22
|
publish-npm:
|
|
23
23
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
24
24
|
needs: build_and_test
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
id-token: write
|
|
25
28
|
runs-on: ubuntu-latest
|
|
26
29
|
steps:
|
|
27
|
-
- uses: actions/checkout@
|
|
28
|
-
- uses: actions/setup-node@
|
|
30
|
+
- uses: actions/checkout@v6
|
|
31
|
+
- uses: actions/setup-node@v6
|
|
29
32
|
with:
|
|
30
|
-
node-version:
|
|
33
|
+
node-version: '24'
|
|
34
|
+
registry-url: 'https://registry.npmjs.org'
|
|
35
|
+
package-manager-cache: false
|
|
31
36
|
- name: Validate release tag
|
|
32
37
|
id: release_meta
|
|
33
38
|
env:
|
|
@@ -55,9 +60,5 @@ jobs:
|
|
|
55
60
|
|
|
56
61
|
echo "tag_version=${TAG_VERSION}" >> "$GITHUB_OUTPUT"
|
|
57
62
|
echo "dist_tag=${DIST_TAG}" >> "$GITHUB_OUTPUT"
|
|
58
|
-
- name: Configure npm auth
|
|
59
|
-
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
|
60
|
-
env:
|
|
61
|
-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
62
63
|
- name: Publish package
|
|
63
64
|
run: npm publish --tag "${{ steps.release_meta.outputs.dist_tag }}"
|
package/index.test.js
CHANGED
|
@@ -5,10 +5,11 @@ const path = require('path');
|
|
|
5
5
|
const xml2js = require('xml2js');
|
|
6
6
|
const R = require('ramda');
|
|
7
7
|
const hash = require('object-hash');
|
|
8
|
+
const js2xmlparser = require('js2xmlparser');
|
|
8
9
|
const { XMLParser } = require('fast-xml-parser');
|
|
9
10
|
const { typeDefs: itineraryProductTypeDefs, query: itineraryProductQuery } = require('./node_modules/ti2/controllers/graphql-schemas/itinerary-product');
|
|
10
11
|
const { typeDefs: itineraryBookingTypeDefs, query: itineraryBookingQuery } = require('./node_modules/ti2/controllers/graphql-schemas/itinerary-booking');
|
|
11
|
-
const { CUSTOM_RATE_ID_NAME } = require('./utils');
|
|
12
|
+
const { CUSTOM_RATE_ID_NAME, hostConnectXmlOptions } = require('./utils');
|
|
12
13
|
const {
|
|
13
14
|
getRatesObjectArray,
|
|
14
15
|
getImmediateLastDateRange,
|
|
@@ -169,6 +170,69 @@ describe('search tests', () => {
|
|
|
169
170
|
expect(newBookingName).toBe(`${'Ae'.repeat(29)}A`);
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
it('serializes valid DOB instead of age and falls back to age for invalid DOB', async () => {
|
|
174
|
+
mockCallTourplan
|
|
175
|
+
.mockImplementationOnce(async () => ({
|
|
176
|
+
AddServiceReply: { BookingId: 'valid-dob' },
|
|
177
|
+
}))
|
|
178
|
+
.mockImplementationOnce(async () => ({
|
|
179
|
+
AddServiceReply: { BookingId: 'invalid-dob' },
|
|
180
|
+
}));
|
|
181
|
+
const payloadFor = passenger => ({
|
|
182
|
+
quoteName: 'DOB serialization test',
|
|
183
|
+
optionId: 'ABC123',
|
|
184
|
+
startDate: '2026-07-03',
|
|
185
|
+
reference: 'TESTREF',
|
|
186
|
+
paxConfigs: [{
|
|
187
|
+
roomType: 'Double',
|
|
188
|
+
adults: 1,
|
|
189
|
+
passengers: [passenger],
|
|
190
|
+
}],
|
|
191
|
+
notes: '',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await app.addServiceToItinerary({
|
|
195
|
+
axios,
|
|
196
|
+
token,
|
|
197
|
+
payload: payloadFor({
|
|
198
|
+
firstName: 'Ada',
|
|
199
|
+
lastName: 'Lovelace',
|
|
200
|
+
passengerType: 'Adult',
|
|
201
|
+
dob: '1990-02-28',
|
|
202
|
+
age: 36,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
await app.addServiceToItinerary({
|
|
206
|
+
axios,
|
|
207
|
+
token,
|
|
208
|
+
payload: payloadFor({
|
|
209
|
+
firstName: 'Grace',
|
|
210
|
+
lastName: 'Hopper',
|
|
211
|
+
passengerType: 'Adult',
|
|
212
|
+
dob: '1990-02-29',
|
|
213
|
+
age: 36,
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const validModel = mockCallTourplan.mock.calls[0][0].model;
|
|
218
|
+
const invalidModel = mockCallTourplan.mock.calls[1][0].model;
|
|
219
|
+
const validPax = validModel.AddServiceRequest.RoomConfigs.RoomConfig[0]
|
|
220
|
+
.PaxList.PaxDetails[0];
|
|
221
|
+
const invalidPax = invalidModel.AddServiceRequest.RoomConfigs.RoomConfig[0]
|
|
222
|
+
.PaxList.PaxDetails[0];
|
|
223
|
+
const validXml = js2xmlparser.parse('Request', validModel, hostConnectXmlOptions);
|
|
224
|
+
const invalidXml = js2xmlparser.parse('Request', invalidModel, hostConnectXmlOptions);
|
|
225
|
+
|
|
226
|
+
expect(validPax).toMatchObject({ DateOfBirth: '1990-02-28' });
|
|
227
|
+
expect(validPax).not.toHaveProperty('Age');
|
|
228
|
+
expect(validXml).toContain('<DateOfBirth>1990-02-28</DateOfBirth>');
|
|
229
|
+
expect(validXml).not.toContain('<Age>');
|
|
230
|
+
expect(invalidPax).toMatchObject({ Age: 36 });
|
|
231
|
+
expect(invalidPax).not.toHaveProperty('DateOfBirth');
|
|
232
|
+
expect(invalidXml).toContain('<Age>36</Age>');
|
|
233
|
+
expect(invalidXml).not.toContain('<DateOfBirth>');
|
|
234
|
+
});
|
|
235
|
+
|
|
172
236
|
it('limits new booking names supplied by directHeaderPayload after escaping XML characters', async () => {
|
|
173
237
|
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
174
238
|
AddServiceReply: {
|
|
@@ -799,66 +863,287 @@ describe('search tests', () => {
|
|
|
799
863
|
expect(retVal).toMatchSnapshot();
|
|
800
864
|
});
|
|
801
865
|
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
866
|
+
describe('searchProductsForItinerary omitServiceCodes', () => {
|
|
867
|
+
const optionGeneral = {
|
|
868
|
+
SupplierId: '1001',
|
|
869
|
+
SupplierName: 'Test Supplier',
|
|
870
|
+
Description: 'Test Product',
|
|
871
|
+
SType: 'N',
|
|
872
|
+
AdultsAllowed: 'Y',
|
|
873
|
+
Adult_From: 16,
|
|
874
|
+
Adult_To: 999,
|
|
875
|
+
ButtonName: 'Sightseeing',
|
|
876
|
+
};
|
|
877
|
+
const acOption = { Opt: 'TESTACOPTION00001', OptGeneral: optionGeneral };
|
|
878
|
+
const bdOption = { Opt: 'TESTBDOPTION00001', OptGeneral: optionGeneral };
|
|
879
|
+
const trOption = { Opt: 'TESTTROPTION00001', OptGeneral: optionGeneral };
|
|
880
|
+
const getServicesReply = {
|
|
881
|
+
GetServicesReply: {
|
|
882
|
+
TPLServices: {
|
|
883
|
+
TPLService: [
|
|
884
|
+
{ Code: 'AC', Name: 'Accommodation' },
|
|
885
|
+
{ Code: 'BD', Name: 'Bundle' },
|
|
886
|
+
{ Code: 'TR', Name: 'Transfer' },
|
|
887
|
+
],
|
|
888
|
+
},
|
|
815
889
|
},
|
|
816
890
|
};
|
|
891
|
+
const optionsByOpt = {
|
|
892
|
+
'???AC????????????': acOption,
|
|
893
|
+
'???BD????????????': bdOption,
|
|
894
|
+
'???TR????????????': trOption,
|
|
895
|
+
};
|
|
896
|
+
const defaultCallTourplan = mockCallTourplan.getMockImplementation();
|
|
897
|
+
const getOptionInfoCalls = () => mockCallTourplan.mock.calls
|
|
898
|
+
.map(([call]) => call.model.OptionInfoRequest)
|
|
899
|
+
.filter(Boolean)
|
|
900
|
+
.map(req => req.Opt);
|
|
817
901
|
|
|
818
|
-
|
|
819
|
-
.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}))
|
|
829
|
-
.mockImplementationOnce(async ({ model }) => {
|
|
830
|
-
const opt = model.OptionInfoRequest.Opt;
|
|
831
|
-
if (opt === '???AC????????????') {
|
|
832
|
-
throw new Error('AC OptionInfo should have been skipped');
|
|
902
|
+
afterEach(() => {
|
|
903
|
+
mockCallTourplan.mockImplementation(defaultCallTourplan);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
const mockFullCatalog = ({ throwOnOpt } = {}) => {
|
|
907
|
+
mockCallTourplan.mockImplementation(async ({ model }) => {
|
|
908
|
+
if (model.GetServicesRequest) return getServicesReply;
|
|
909
|
+
const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
|
|
910
|
+
if (throwOnOpt && throwOnOpt.includes(opt)) {
|
|
911
|
+
throw new Error(`${opt} OptionInfo should have been skipped`);
|
|
833
912
|
}
|
|
834
|
-
if (opt
|
|
835
|
-
return { OptionInfoReply: { Option:
|
|
913
|
+
if (optionsByOpt[opt]) {
|
|
914
|
+
return { OptionInfoReply: { Option: optionsByOpt[opt] } };
|
|
836
915
|
}
|
|
837
916
|
throw new Error(`Unexpected OptionInfo Opt: ${opt}`);
|
|
838
917
|
});
|
|
918
|
+
};
|
|
839
919
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
920
|
+
it('fetches all service codes when omitServiceCodes is not provided', async () => {
|
|
921
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
922
|
+
mockFullCatalog();
|
|
923
|
+
|
|
924
|
+
const retVal = await app.searchProductsForItinerary({
|
|
925
|
+
axios,
|
|
926
|
+
token,
|
|
927
|
+
typeDefsAndQueries,
|
|
928
|
+
payload: { searchInput: '' },
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
expect(getOptionInfoCalls()).toEqual([
|
|
932
|
+
'???AC????????????',
|
|
933
|
+
'???BD????????????',
|
|
934
|
+
'???TR????????????',
|
|
935
|
+
]);
|
|
936
|
+
expect(debugSpy).not.toHaveBeenCalled();
|
|
937
|
+
expect(retVal.products).toHaveLength(1);
|
|
938
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
939
|
+
'TESTACOPTION00001',
|
|
940
|
+
'TESTBDOPTION00001',
|
|
941
|
+
'TESTTROPTION00001',
|
|
942
|
+
]);
|
|
943
|
+
debugSpy.mockRestore();
|
|
848
944
|
});
|
|
849
945
|
|
|
850
|
-
|
|
851
|
-
.
|
|
852
|
-
|
|
853
|
-
expect(optionInfoCalls).toHaveLength(1);
|
|
854
|
-
expect(optionInfoCalls[0].Opt).toBe('???BD????????????');
|
|
855
|
-
expect(debugSpy).toHaveBeenCalledWith(
|
|
856
|
-
'[tourplan] Accommodation (AC) service code will be skipped in full catalog product search',
|
|
857
|
-
);
|
|
858
|
-
expect(retVal.products).toHaveLength(1);
|
|
859
|
-
expect(retVal.products[0].options[0].optionId).toBe('TESTBDOPTION00001');
|
|
946
|
+
it('fetches all service codes when omitServiceCodes is empty', async () => {
|
|
947
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
948
|
+
mockFullCatalog();
|
|
860
949
|
|
|
861
|
-
|
|
950
|
+
const retVal = await app.searchProductsForItinerary({
|
|
951
|
+
axios,
|
|
952
|
+
token,
|
|
953
|
+
typeDefsAndQueries,
|
|
954
|
+
payload: {
|
|
955
|
+
searchInput: '',
|
|
956
|
+
omitServiceCodes: [],
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
expect(getOptionInfoCalls()).toEqual([
|
|
961
|
+
'???AC????????????',
|
|
962
|
+
'???BD????????????',
|
|
963
|
+
'???TR????????????',
|
|
964
|
+
]);
|
|
965
|
+
expect(debugSpy).not.toHaveBeenCalled();
|
|
966
|
+
expect(retVal.products[0].options).toHaveLength(3);
|
|
967
|
+
debugSpy.mockRestore();
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('omits a single listed service code', async () => {
|
|
971
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
972
|
+
mockFullCatalog({ throwOnOpt: ['???AC????????????'] });
|
|
973
|
+
|
|
974
|
+
const retVal = await app.searchProductsForItinerary({
|
|
975
|
+
axios,
|
|
976
|
+
token,
|
|
977
|
+
typeDefsAndQueries,
|
|
978
|
+
payload: {
|
|
979
|
+
searchInput: '',
|
|
980
|
+
omitServiceCodes: ['AC'],
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
expect(getOptionInfoCalls()).toEqual([
|
|
985
|
+
'???BD????????????',
|
|
986
|
+
'???TR????????????',
|
|
987
|
+
]);
|
|
988
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
989
|
+
'[tourplan] Omitting service code(s) from full catalog product search: AC',
|
|
990
|
+
);
|
|
991
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
992
|
+
'TESTBDOPTION00001',
|
|
993
|
+
'TESTTROPTION00001',
|
|
994
|
+
]);
|
|
995
|
+
debugSpy.mockRestore();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('omits multiple listed service codes', async () => {
|
|
999
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
1000
|
+
mockFullCatalog({ throwOnOpt: ['???AC????????????', '???TR????????????'] });
|
|
1001
|
+
|
|
1002
|
+
const retVal = await app.searchProductsForItinerary({
|
|
1003
|
+
axios,
|
|
1004
|
+
token,
|
|
1005
|
+
typeDefsAndQueries,
|
|
1006
|
+
payload: {
|
|
1007
|
+
searchInput: '',
|
|
1008
|
+
omitServiceCodes: ['AC', 'TR'],
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
expect(getOptionInfoCalls()).toEqual(['???BD????????????']);
|
|
1013
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1014
|
+
'[tourplan] Omitting service code(s) from full catalog product search: AC, TR',
|
|
1015
|
+
);
|
|
1016
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
1017
|
+
'TESTBDOPTION00001',
|
|
1018
|
+
]);
|
|
1019
|
+
debugSpy.mockRestore();
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('coerces a single string omitServiceCodes value to an array', async () => {
|
|
1023
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
1024
|
+
mockFullCatalog({ throwOnOpt: ['???AC????????????'] });
|
|
1025
|
+
|
|
1026
|
+
const retVal = await app.searchProductsForItinerary({
|
|
1027
|
+
axios,
|
|
1028
|
+
token,
|
|
1029
|
+
typeDefsAndQueries,
|
|
1030
|
+
payload: {
|
|
1031
|
+
searchInput: '',
|
|
1032
|
+
omitServiceCodes: 'AC',
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
expect(getOptionInfoCalls()).toEqual([
|
|
1037
|
+
'???BD????????????',
|
|
1038
|
+
'???TR????????????',
|
|
1039
|
+
]);
|
|
1040
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1041
|
+
'[tourplan] Omitting service code(s) from full catalog product search: AC',
|
|
1042
|
+
);
|
|
1043
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
1044
|
+
'TESTBDOPTION00001',
|
|
1045
|
+
'TESTTROPTION00001',
|
|
1046
|
+
]);
|
|
1047
|
+
debugSpy.mockRestore();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('normalizes case and whitespace in omitServiceCodes', async () => {
|
|
1051
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
1052
|
+
mockFullCatalog({ throwOnOpt: ['???AC????????????'] });
|
|
1053
|
+
|
|
1054
|
+
const retVal = await app.searchProductsForItinerary({
|
|
1055
|
+
axios,
|
|
1056
|
+
token,
|
|
1057
|
+
typeDefsAndQueries,
|
|
1058
|
+
payload: {
|
|
1059
|
+
searchInput: '',
|
|
1060
|
+
omitServiceCodes: [' ac '],
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
expect(getOptionInfoCalls()).toEqual([
|
|
1065
|
+
'???BD????????????',
|
|
1066
|
+
'???TR????????????',
|
|
1067
|
+
]);
|
|
1068
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1069
|
+
'[tourplan] Omitting service code(s) from full catalog product search: AC',
|
|
1070
|
+
);
|
|
1071
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
1072
|
+
'TESTBDOPTION00001',
|
|
1073
|
+
'TESTTROPTION00001',
|
|
1074
|
+
]);
|
|
1075
|
+
debugSpy.mockRestore();
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it('splits a comma-separated omitServiceCodes string', async () => {
|
|
1079
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
1080
|
+
mockFullCatalog({ throwOnOpt: ['???AC????????????', '???TR????????????'] });
|
|
1081
|
+
|
|
1082
|
+
const retVal = await app.searchProductsForItinerary({
|
|
1083
|
+
axios,
|
|
1084
|
+
token,
|
|
1085
|
+
typeDefsAndQueries,
|
|
1086
|
+
payload: {
|
|
1087
|
+
searchInput: '',
|
|
1088
|
+
omitServiceCodes: 'AC, TR',
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
expect(getOptionInfoCalls()).toEqual(['???BD????????????']);
|
|
1093
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1094
|
+
'[tourplan] Omitting service code(s) from full catalog product search: AC, TR',
|
|
1095
|
+
);
|
|
1096
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
1097
|
+
'TESTBDOPTION00001',
|
|
1098
|
+
]);
|
|
1099
|
+
debugSpy.mockRestore();
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it('splits comma-separated entries inside an omitServiceCodes array', async () => {
|
|
1103
|
+
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
1104
|
+
mockFullCatalog({ throwOnOpt: ['???AC????????????', '???TR????????????'] });
|
|
1105
|
+
|
|
1106
|
+
const retVal = await app.searchProductsForItinerary({
|
|
1107
|
+
axios,
|
|
1108
|
+
token,
|
|
1109
|
+
typeDefsAndQueries,
|
|
1110
|
+
payload: {
|
|
1111
|
+
searchInput: '',
|
|
1112
|
+
omitServiceCodes: ['AC,SM', ' tr '],
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
expect(getOptionInfoCalls()).toEqual(['???BD????????????']);
|
|
1117
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1118
|
+
'[tourplan] Omitting service code(s) from full catalog product search: AC, SM, TR',
|
|
1119
|
+
);
|
|
1120
|
+
expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
|
|
1121
|
+
'TESTBDOPTION00001',
|
|
1122
|
+
]);
|
|
1123
|
+
debugSpy.mockRestore();
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
it('throws No products found when every service code is omitted', async () => {
|
|
1127
|
+
mockFullCatalog({
|
|
1128
|
+
throwOnOpt: [
|
|
1129
|
+
'???AC????????????',
|
|
1130
|
+
'???BD????????????',
|
|
1131
|
+
'???TR????????????',
|
|
1132
|
+
],
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
await expect(app.searchProductsForItinerary({
|
|
1136
|
+
axios,
|
|
1137
|
+
token,
|
|
1138
|
+
typeDefsAndQueries,
|
|
1139
|
+
payload: {
|
|
1140
|
+
searchInput: '',
|
|
1141
|
+
omitServiceCodes: ['AC', 'BD', 'TR'],
|
|
1142
|
+
},
|
|
1143
|
+
})).rejects.toThrow('No products found');
|
|
1144
|
+
|
|
1145
|
+
expect(getOptionInfoCalls()).toEqual([]);
|
|
1146
|
+
});
|
|
862
1147
|
});
|
|
863
1148
|
|
|
864
1149
|
describe('availability tests', () => {
|
|
@@ -22,8 +22,9 @@ const searchProductsForItinerary = async ({
|
|
|
22
22
|
// lastUpdatedFrom is used to get options that were updated after a certain date in Tourplan
|
|
23
23
|
// example: lastUpdatedFrom: '2024-04-22 05:17:57.427Z'
|
|
24
24
|
lastUpdatedFrom,
|
|
25
|
-
//
|
|
26
|
-
|
|
25
|
+
// service codes to omit from full catalog search
|
|
26
|
+
// (e.g. ['AC'], 'AC', or 'AC,SM'); when empty/absent, get all
|
|
27
|
+
omitServiceCodes,
|
|
27
28
|
},
|
|
28
29
|
callTourplan,
|
|
29
30
|
}) => {
|
|
@@ -60,7 +61,7 @@ const searchProductsForItinerary = async ({
|
|
|
60
61
|
/*
|
|
61
62
|
Full catalog path:
|
|
62
63
|
1. getServiceCodes -> [AC, BD]
|
|
63
|
-
2. for each serviceCode getoptions (
|
|
64
|
+
2. for each serviceCode getoptions (omitting all service codes in omitServiceCodes)
|
|
64
65
|
3. convert them to ti2 products structure
|
|
65
66
|
4. merge all products from all serviceCodes
|
|
66
67
|
*/
|
|
@@ -79,12 +80,26 @@ const searchProductsForItinerary = async ({
|
|
|
79
80
|
});
|
|
80
81
|
let serviceCodes = R.pathOr([], ['GetServicesReply', 'TPLServices', 'TPLService'], getServicesReply);
|
|
81
82
|
if (!Array.isArray(serviceCodes)) serviceCodes = [serviceCodes];
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
let rawOmitCodes = [];
|
|
84
|
+
if (Array.isArray(omitServiceCodes)) {
|
|
85
|
+
rawOmitCodes = omitServiceCodes;
|
|
86
|
+
} else if (omitServiceCodes) {
|
|
87
|
+
rawOmitCodes = [omitServiceCodes];
|
|
88
|
+
}
|
|
89
|
+
const omitSet = new Set(
|
|
90
|
+
rawOmitCodes
|
|
91
|
+
.flatMap(code => String(code == null ? '' : code).split(','))
|
|
92
|
+
.map(code => code.trim().toUpperCase())
|
|
93
|
+
.filter(Boolean),
|
|
94
|
+
);
|
|
95
|
+
if (omitSet.size > 0) {
|
|
96
|
+
console.debug(
|
|
97
|
+
`[tourplan] Omitting service code(s) from full catalog product search: ${[...omitSet].join(', ')}`,
|
|
98
|
+
);
|
|
84
99
|
}
|
|
85
100
|
serviceCodes = serviceCodes
|
|
86
101
|
.map(s => s.Code)
|
|
87
|
-
.filter(code => !(
|
|
102
|
+
.filter(code => !omitSet.has(String(code).trim().toUpperCase()));
|
|
88
103
|
await Promise.each(serviceCodes, async serviceCode => {
|
|
89
104
|
const getOptionsModel = {
|
|
90
105
|
OptionInfoRequest: {
|
package/package.json
CHANGED
package/utils.js
CHANGED
|
@@ -71,6 +71,26 @@ const escapeInvalidXmlChars = str => {
|
|
|
71
71
|
.replace(BAD_XML_CHARS, '');
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
const getValidDateOfBirth = dob => {
|
|
75
|
+
if (typeof dob !== 'string') return undefined;
|
|
76
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dob);
|
|
77
|
+
if (!match) return undefined;
|
|
78
|
+
|
|
79
|
+
const year = Number(match[1]);
|
|
80
|
+
const month = Number(match[2]);
|
|
81
|
+
const day = Number(match[3]);
|
|
82
|
+
if (year === 0) return undefined;
|
|
83
|
+
|
|
84
|
+
// setUTCFullYear avoids Date.UTC's 0-99 year remapping.
|
|
85
|
+
const date = new Date(0);
|
|
86
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
87
|
+
if (date.getUTCFullYear() !== year ||
|
|
88
|
+
date.getUTCMonth() !== month - 1 ||
|
|
89
|
+
date.getUTCDate() !== day) return undefined;
|
|
90
|
+
|
|
91
|
+
return dob;
|
|
92
|
+
};
|
|
93
|
+
|
|
74
94
|
const getRoomConfigs = (paxConfigs, noPaxList) => {
|
|
75
95
|
// There should be only 1 RoomConfigs for AddServiceRequest
|
|
76
96
|
const RoomConfigs = {};
|
|
@@ -139,12 +159,13 @@ const getRoomConfigs = (paxConfigs, noPaxList) => {
|
|
|
139
159
|
}[p.passengerType] || 'A',
|
|
140
160
|
};
|
|
141
161
|
if (p.salutation) EachPaxDetails.Title = escapeInvalidXmlChars(p.salutation);
|
|
142
|
-
|
|
162
|
+
const validDateOfBirth = getValidDateOfBirth(p.dob);
|
|
163
|
+
if (validDateOfBirth) EachPaxDetails.DateOfBirth = validDateOfBirth;
|
|
143
164
|
// NOTE: TourPlan API doesn't accept age as empty string, i.e. empty XML tag <Age/>
|
|
144
165
|
// and trhows and error like - "1000 SCN System.InvalidOperationException: There is an
|
|
145
166
|
// error in XML document (29, 8). (Input string was not in a correct format.)"
|
|
146
167
|
// The solution is to NOT send the Age tag if it's empty
|
|
147
|
-
if (!R.isNil(p.age) && !Number.isNaN(p.age) && p.age) {
|
|
168
|
+
if (!validDateOfBirth && !R.isNil(p.age) && !Number.isNaN(p.age) && p.age) {
|
|
148
169
|
if (!(p.passengerType === passengerTypeMap.Adult && p.age === 0)) {
|
|
149
170
|
EachPaxDetails.Age = p.age;
|
|
150
171
|
}
|
package/utils.test.js
CHANGED
|
@@ -1,4 +1,105 @@
|
|
|
1
|
-
const { escapeInvalidXmlChars } = require('./utils');
|
|
1
|
+
const { escapeInvalidXmlChars, getRoomConfigs } = require('./utils');
|
|
2
|
+
|
|
3
|
+
describe('getRoomConfigs', () => {
|
|
4
|
+
const getPaxDetails = passenger => getRoomConfigs([{
|
|
5
|
+
roomType: 'Double',
|
|
6
|
+
passengers: [passenger],
|
|
7
|
+
}]).RoomConfig[0].PaxList.PaxDetails[0];
|
|
8
|
+
|
|
9
|
+
it('uses a valid DOB instead of age', () => {
|
|
10
|
+
const withDobAndAge = getPaxDetails({
|
|
11
|
+
firstName: 'Ada',
|
|
12
|
+
lastName: 'Lovelace',
|
|
13
|
+
passengerType: 'Adult',
|
|
14
|
+
dob: '1990-02-28',
|
|
15
|
+
age: 36,
|
|
16
|
+
});
|
|
17
|
+
const withDobOnly = getPaxDetails({
|
|
18
|
+
passengerType: 'Child',
|
|
19
|
+
dob: '2000-02-29',
|
|
20
|
+
});
|
|
21
|
+
const withPre100Dob = getPaxDetails({
|
|
22
|
+
passengerType: 'Adult',
|
|
23
|
+
dob: '0099-12-31',
|
|
24
|
+
age: 99,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(withDobAndAge.DateOfBirth).toBe('1990-02-28');
|
|
28
|
+
expect(withDobAndAge).not.toHaveProperty('Age');
|
|
29
|
+
expect(withDobOnly.DateOfBirth).toBe('2000-02-29');
|
|
30
|
+
expect(withDobOnly).not.toHaveProperty('Age');
|
|
31
|
+
expect(withPre100Dob.DateOfBirth).toBe('0099-12-31');
|
|
32
|
+
expect(withPre100Dob).not.toHaveProperty('Age');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('uses age when DOB is absent', () => {
|
|
36
|
+
const paxDetails = getPaxDetails({
|
|
37
|
+
passengerType: 'Adult',
|
|
38
|
+
age: 36,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(paxDetails.Age).toBe(36);
|
|
42
|
+
expect(paxDetails).not.toHaveProperty('DateOfBirth');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
['blank', ''],
|
|
47
|
+
['malformed', '1990/02/28'],
|
|
48
|
+
['unpadded', '1990-2-28'],
|
|
49
|
+
['date-time', '1990-02-28T00:00:00Z'],
|
|
50
|
+
['boxed string', Object('1990-02-28')],
|
|
51
|
+
['Date object', new Date('1990-02-28T00:00:00Z')],
|
|
52
|
+
['number', 19900228],
|
|
53
|
+
])('falls back to age for a %s DOB', (description, dob) => {
|
|
54
|
+
const paxDetails = getPaxDetails({
|
|
55
|
+
passengerType: 'Adult',
|
|
56
|
+
dob,
|
|
57
|
+
age: 36,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(paxDetails.Age).toBe(36);
|
|
61
|
+
expect(paxDetails).not.toHaveProperty('DateOfBirth');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each([
|
|
65
|
+
['year zero', '0000-01-01'],
|
|
66
|
+
['month zero', '1990-00-01'],
|
|
67
|
+
['day zero', '1990-01-00'],
|
|
68
|
+
['day rollover', '1990-04-31'],
|
|
69
|
+
['non-leap day', '2025-02-29'],
|
|
70
|
+
['non-leap century day', '1900-02-29'],
|
|
71
|
+
])('falls back to age for an impossible %s DOB', (description, dob) => {
|
|
72
|
+
const paxDetails = getPaxDetails({
|
|
73
|
+
passengerType: 'Adult',
|
|
74
|
+
dob,
|
|
75
|
+
age: 36,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(paxDetails.Age).toBe(36);
|
|
79
|
+
expect(paxDetails).not.toHaveProperty('DateOfBirth');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('preserves the PersonId early return', () => {
|
|
83
|
+
expect(getPaxDetails({
|
|
84
|
+
personId: 'person-123',
|
|
85
|
+
passengerType: 'Adult',
|
|
86
|
+
dob: '1990-02-28',
|
|
87
|
+
age: 36,
|
|
88
|
+
})).toEqual({ PersonId: 'person-123' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('preserves zero, blank, and NaN age behavior without a valid DOB', () => {
|
|
92
|
+
const adultZero = getPaxDetails({ passengerType: 'Adult', age: 0 });
|
|
93
|
+
const childZero = getPaxDetails({ passengerType: 'Child', age: 0 });
|
|
94
|
+
const blank = getPaxDetails({ passengerType: 'Adult', age: '' });
|
|
95
|
+
const nan = getPaxDetails({ passengerType: 'Adult', age: NaN });
|
|
96
|
+
|
|
97
|
+
[adultZero, childZero, blank, nan].forEach(paxDetails => {
|
|
98
|
+
expect(paxDetails).not.toHaveProperty('Age');
|
|
99
|
+
expect(paxDetails).not.toHaveProperty('DateOfBirth');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
2
103
|
|
|
3
104
|
describe('escapeInvalidXmlChars', () => {
|
|
4
105
|
it('returns empty string for falsy input', () => {
|