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.
@@ -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@v2
28
- - uses: actions/setup-node@v1
30
+ - uses: actions/checkout@v6
31
+ - uses: actions/setup-node@v6
29
32
  with:
30
- node-version: 12
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
- it('searchProductsForItinerary skips AC in full catalog when skipAccommodation is set', async () => {
803
- const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
804
- const bdOption = {
805
- Opt: 'TESTBDOPTION00001',
806
- OptGeneral: {
807
- SupplierId: '1001',
808
- SupplierName: 'Test Supplier',
809
- Description: 'Test BD Product',
810
- SType: 'N',
811
- AdultsAllowed: 'Y',
812
- Adult_From: 16,
813
- Adult_To: 999,
814
- ButtonName: 'Sightseeing',
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
- mockCallTourplan
819
- .mockImplementationOnce(async () => ({
820
- GetServicesReply: {
821
- TPLServices: {
822
- TPLService: [
823
- { Code: 'AC', Name: 'Accommodation' },
824
- { Code: 'BD', Name: 'Bundle' },
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 === '???BD????????????') {
835
- return { OptionInfoReply: { Option: bdOption } };
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
- const retVal = await app.searchProductsForItinerary({
841
- axios,
842
- token,
843
- typeDefsAndQueries,
844
- payload: {
845
- searchInput: '',
846
- skipAccommodation: true,
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
- const optionInfoCalls = mockCallTourplan.mock.calls
851
- .map(([call]) => call.model.OptionInfoRequest)
852
- .filter(Boolean);
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
- debugSpy.mockRestore();
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
- // skip Accommodation (AC) service code in full catalog search
26
- skipAccommodation,
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 (optionally skip AC when skipAccommodation is set)
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
- if (skipAccommodation) {
83
- console.debug('[tourplan] Accommodation (AC) service code will be skipped in full catalog product search');
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 => !(skipAccommodation && code === 'AC'));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.128",
3
+ "version": "1.0.129",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
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
- if (p.dob) EachPaxDetails.DateOfBirth = p.dob;
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', () => {