ti2-ventrata 1.0.40 → 1.0.45

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/index.js CHANGED
@@ -29,6 +29,30 @@ const getHeaders = ({
29
29
  // 'Octo-Capabilities': 'octo/pricing',
30
30
  });
31
31
 
32
+ const extractVentrataErrorMessage = err => {
33
+ const responseData = err && err.response && err.response.data;
34
+ if (!responseData) return err && err.message;
35
+ if (typeof responseData === 'string') return responseData;
36
+ if (responseData.errorMessage) return responseData.errorMessage;
37
+ if (responseData.message) return responseData.message;
38
+ if (Array.isArray(responseData.errors) && responseData.errors.length > 0) {
39
+ const firstError = responseData.errors[0];
40
+ if (typeof firstError === 'string') return firstError;
41
+ if (firstError && firstError.message) return firstError.message;
42
+ }
43
+ return err && err.message;
44
+ };
45
+
46
+ const throwWithVentrataErrorMessage = (error, fallbackMessage) => {
47
+ const normalizedMessage = extractVentrataErrorMessage(error) || fallbackMessage;
48
+ if (error && typeof error === 'object') {
49
+ const wrappedError = Object.assign(new Error(normalizedMessage), error);
50
+ wrappedError.message = normalizedMessage;
51
+ throw wrappedError;
52
+ }
53
+ throw new Error(normalizedMessage);
54
+ };
55
+
32
56
  class Plugin {
33
57
  constructor(params) { // we get the env variables from here
34
58
  Object.entries(params).forEach(([attr, value]) => {
@@ -390,7 +414,9 @@ class Plugin {
390
414
  notes,
391
415
  reference,
392
416
  pickupPoint,
417
+ pickupPointId,
393
418
  customFieldValues,
419
+ participants,
394
420
  },
395
421
  typeDefsAndQueries: {
396
422
  bookingTypeDefs,
@@ -407,33 +433,110 @@ class Plugin {
407
433
  acceptLanguage,
408
434
  });
409
435
  const dataForCreateBooking = await jwt.verify(availabilityKey, this.jwtKey);
410
- // if customFieldValues contains perUnitItem fields
411
- // we need to overwrite unitItems from the availabilityKey
412
- if (customFieldValues && customFieldValues.length) {
413
- const productCFV = customFieldValues.filter(o => !R.isNil(o.value) && !o.field.isPerUnitItem);
414
- const unitItemCFV = customFieldValues.filter(o => !R.isNil(o.value) && o.field.isPerUnitItem);
415
- if (productCFV.length) {
416
- dataForCreateBooking.questionAnswers = productCFV.map(o => ({
417
- questionId: o.field.id,
418
- value: o.value,
419
- }));
436
+ const safeCustomFieldValues = Array.isArray(customFieldValues) ? customFieldValues : [];
437
+ const safeParticipants = Array.isArray(participants) ? participants : [];
438
+ const normalizeAnswerValue = rawValue => {
439
+ // Some TI2 inputs (e.g. option/select fields) can reach us as
440
+ // { label, value, userInput }. Ventrata questionAnswers.value must be scalar.
441
+ if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
442
+ if (Object.prototype.hasOwnProperty.call(rawValue, 'value')) {
443
+ return rawValue.value;
444
+ }
420
445
  }
421
- if (unitItemCFV.length) {
422
- dataForCreateBooking.unitItems = R.call(R.compose(
423
- R.map(arr => ({
424
- unitId: arr[0].field.unitId,
425
- questionAnswers: arr.map(o => ({
426
- questionId: o.field.id.split('|')[0],
427
- value: o.value,
428
- })),
429
- })),
430
- R.values,
431
- R.groupBy(o => {
432
- const [, unitItemIndex] = o.field.id.split('|');
433
- return unitItemIndex;
434
- }),
435
- ), unitItemCFV);
446
+ return rawValue;
447
+ };
448
+ const parsedInt = value => {
449
+ const num = Number(value);
450
+ return Number.isInteger(num) ? num : null;
451
+ };
452
+ const upsertQuestionAnswer = (answers, nextAnswer) => {
453
+ const ix = answers.findIndex(existing => existing.questionId === nextAnswer.questionId);
454
+ if (ix > -1) answers[ix] = nextAnswer;
455
+ else answers.push(nextAnswer);
456
+ };
457
+ const normalizeQuestionId = value => `${value || ''}`.split('|')[0];
458
+ const bookingQuestionAnswers = [];
459
+ const unitItemAnswerCandidates = [];
460
+ const addAnswerEntry = (entry, participantIndex = null) => {
461
+ if (!entry || R.isNil(entry.value)) return;
462
+ const field = entry.field || {};
463
+ const rawFieldId = `${field.id || ''}`;
464
+ const questionId = normalizeQuestionId(rawFieldId);
465
+ if (!questionId) return;
466
+ const normalizedValue = normalizeAnswerValue(entry.value);
467
+ if (R.isNil(normalizedValue)) return;
468
+ const [, unitItemIndexRaw] = rawFieldId.split('|');
469
+ const unitItemIndexFromId = parsedInt(unitItemIndexRaw);
470
+ const shouldRouteToUnitItem = Boolean(field.isPerUnitItem)
471
+ || unitItemIndexFromId !== null
472
+ || (!R.isNil(participantIndex) && Boolean(field.unitId));
473
+ if (!shouldRouteToUnitItem) {
474
+ if (R.isNil(participantIndex)) {
475
+ bookingQuestionAnswers.push({ questionId, value: normalizedValue });
476
+ }
477
+ return;
436
478
  }
479
+ unitItemAnswerCandidates.push({
480
+ questionId,
481
+ value: normalizedValue,
482
+ unitId: field.unitId,
483
+ unitItemIndex: unitItemIndexFromId,
484
+ participantIndex,
485
+ });
486
+ };
487
+ safeCustomFieldValues.forEach(entry => addAnswerEntry(entry));
488
+ safeParticipants.forEach((participant, participantIndex) => {
489
+ const allParticipantEntries = [
490
+ ...R.pathOr([], ['fields'], participant),
491
+ ...R.pathOr([], ['customFieldValues'], participant),
492
+ ];
493
+ allParticipantEntries.forEach(entry => addAnswerEntry(entry, participantIndex));
494
+ });
495
+ if (bookingQuestionAnswers.length) {
496
+ const existingQuestionAnswers = Array.isArray(dataForCreateBooking.questionAnswers)
497
+ ? [...dataForCreateBooking.questionAnswers]
498
+ : [];
499
+ bookingQuestionAnswers.forEach(answer => {
500
+ upsertQuestionAnswer(existingQuestionAnswers, answer);
501
+ });
502
+ dataForCreateBooking.questionAnswers = existingQuestionAnswers;
503
+ }
504
+ if (unitItemAnswerCandidates.length) {
505
+ const nextUnitItems = Array.isArray(dataForCreateBooking.unitItems)
506
+ ? dataForCreateBooking.unitItems.map(unitItem => ({
507
+ ...unitItem,
508
+ questionAnswers: Array.isArray(unitItem.questionAnswers) ? [...unitItem.questionAnswers] : [],
509
+ }))
510
+ : [];
511
+ unitItemAnswerCandidates.forEach(candidate => {
512
+ const candidateIndex = (
513
+ candidate.unitItemIndex !== null
514
+ ? candidate.unitItemIndex
515
+ : (
516
+ !R.isNil(candidate.participantIndex)
517
+ ? candidate.participantIndex
518
+ : nextUnitItems.findIndex(item => item.unitId === candidate.unitId)
519
+ )
520
+ );
521
+ let targetIndex = candidateIndex;
522
+ if (!(targetIndex >= 0 && targetIndex < nextUnitItems.length)) {
523
+ if (candidate.unitId) {
524
+ nextUnitItems.push({
525
+ unitId: candidate.unitId,
526
+ questionAnswers: [],
527
+ });
528
+ targetIndex = nextUnitItems.length - 1;
529
+ } else {
530
+ return;
531
+ }
532
+ }
533
+ const targetItem = nextUnitItems[targetIndex];
534
+ upsertQuestionAnswer(targetItem.questionAnswers, {
535
+ questionId: candidate.questionId,
536
+ value: candidate.value,
537
+ });
538
+ });
539
+ dataForCreateBooking.unitItems = nextUnitItems;
437
540
  }
438
541
  const { settlementMethods } = dataForCreateBooking;
439
542
  const settlementMethod = Plugin.getSettlementMethod(
@@ -441,18 +544,26 @@ class Plugin {
441
544
  settlementMethods,
442
545
  );
443
546
 
444
- let booking = R.path(['data'], await axios({
445
- method: rebookingId ? 'patch' : 'post',
446
- url: `${endpoint || this.endpoint}/bookings${rebookingId ? `/${rebookingId}` : ''}`,
447
- data: {
448
- orderId,
449
- settlementMethod,
450
- ...R.omit(['settlementMethods'], dataForCreateBooking),
451
- notes,
452
- ...(pickupPoint ? { pickupRequested: true, pickupPointId: pickupPoint } : {}),
453
- },
454
- headers,
455
- }));
547
+ let booking;
548
+ try {
549
+ booking = R.path(['data'], await axios({
550
+ method: rebookingId ? 'patch' : 'post',
551
+ url: `${endpoint || this.endpoint}/bookings${rebookingId ? `/${rebookingId}` : ''}`,
552
+ data: {
553
+ orderId,
554
+ settlementMethod,
555
+ ...R.omit(['settlementMethods'], dataForCreateBooking),
556
+ notes,
557
+ ...((pickupPoint || pickupPointId) ? {
558
+ pickupRequested: true,
559
+ pickupPointId: pickupPoint || pickupPointId,
560
+ } : {}),
561
+ },
562
+ headers,
563
+ }));
564
+ } catch (error) {
565
+ throwWithVentrataErrorMessage(error, 'Failed to create booking');
566
+ }
456
567
  // for booking update, we may not need to confirm again
457
568
  if (!booking.utcConfirmedAt && !partialOrder) {
458
569
  const validatedCountry = validateCountry(R.pathOr('', ['country'], holder));
@@ -500,7 +611,7 @@ class Plugin {
500
611
  }));
501
612
  } catch (error) {
502
613
  console.error('Booking confirmation failed:', (error.response && error.response.data) ? error.response.data : error.message);
503
- throw error;
614
+ throwWithVentrataErrorMessage(error, 'Failed to confirm booking');
504
615
  }
505
616
  }
506
617
  return ({
@@ -566,7 +677,7 @@ class Plugin {
566
677
  },
567
678
  payload: {
568
679
  bookingId,
569
- resellerReference,
680
+ reference,
570
681
  supplierBookingId,
571
682
  travelDateStart,
572
683
  travelDateEnd,
@@ -579,7 +690,7 @@ class Plugin {
579
690
  }) {
580
691
  assert(
581
692
  !isNilOrEmpty(bookingId)
582
- || !isNilOrEmpty(resellerReference)
693
+ || !isNilOrEmpty(reference)
583
694
  || !isNilOrEmpty(supplierBookingId)
584
695
  || !(
585
696
  isNilOrEmpty(travelDateStart) && isNilOrEmpty(travelDateEnd) && isNilOrEmpty(dateFormat)
@@ -613,8 +724,8 @@ class Plugin {
613
724
  searchByUrl(`${endpoint || this.endpoint}/bookings?supplierReference=${bookingId}`),
614
725
  ]);
615
726
  }
616
- if (!isNilOrEmpty(resellerReference)) {
617
- url = `${endpoint || this.endpoint}/bookings?resellerReference=${resellerReference}`;
727
+ if (!isNilOrEmpty(reference)) {
728
+ url = `${endpoint || this.endpoint}/bookings?resellerReference=${reference}`;
618
729
  return R.path(['data'], await axios({
619
730
  method: 'get',
620
731
  url,
@@ -715,8 +826,14 @@ class Plugin {
715
826
  });
716
827
  const convertQToField = o => ({
717
828
  id: o.id,
829
+ ...(o.unitId ? { unitId: o.unitId } : {}),
718
830
  subtitle: o.description === o.label ? '' : o.description,
719
831
  title: o.label,
832
+ required: Boolean(o.required),
833
+ requiredPerBooking: Boolean(o.required),
834
+ requiredPerParticipant: Boolean(o.isPerUnitItem && o.required),
835
+ visiblePerBooking: true,
836
+ visiblePerParticipant: Boolean(o.isPerUnitItem),
720
837
  type: (() => {
721
838
  if (o.inputType === 'radio') return 'radio';
722
839
  if (Array.isArray(o.selectOptions) && o.selectOptions.length) return 'extended-option';
@@ -728,6 +845,19 @@ class Plugin {
728
845
  options: o.selectOptions,
729
846
  isPerUnitItem: o.isPerUnitItem,
730
847
  });
848
+ const normalizeQuestion = ({ question, context }) => ({
849
+ ...question,
850
+ isPerUnitItem: Boolean(context.isPerUnitItem),
851
+ ...(context.unitId ? { unitId: context.unitId } : {}),
852
+ });
853
+ const collectQuestions = roots => R.call(R.compose(
854
+ R.uniqBy(q => `${q.id}|${q.unitId || ''}|${Boolean(q.isPerUnitItem)}`),
855
+ R.chain(obj => (obj.questions || []).map(question => normalizeQuestion({
856
+ question,
857
+ context: obj,
858
+ }))),
859
+ R.filter(o => o.questions && o.questions.length),
860
+ ), roots);
731
861
  if (!productId) {
732
862
  const allProducts = R.pathOr([], ['data'], await axios({
733
863
  method: 'get',
@@ -735,15 +865,12 @@ class Plugin {
735
865
  headers,
736
866
  }));
737
867
  const allOptions = R.chain(R.propOr([], 'options'), allProducts);
738
- const allUnits = R.chain(R.propOr([], 'units'), allOptions).map(o => ({ ...o, isPerUnitItem: true }));
739
- const allQuestions = R.call(R.compose(
740
- R.uniqBy(R.prop('id')),
741
- R.chain(obj => (obj.questions || []).map(q => ({
742
- ...q,
743
- isPerUnitItem: Boolean(obj.isPerUnitItem),
744
- }))),
745
- R.filter(o => o.questions && o.questions.length),
746
- ), [...allOptions, ...allUnits]);
868
+ const allUnits = R.chain(R.propOr([], 'units'), allOptions).map(u => ({
869
+ ...u,
870
+ isPerUnitItem: true,
871
+ unitId: u.id,
872
+ }));
873
+ const allQuestions = collectQuestions([...allOptions, ...allUnits]);
747
874
  return {
748
875
  fields: [],
749
876
  customFields: allQuestions.map(convertQToField),
@@ -757,16 +884,13 @@ class Plugin {
757
884
  }));
758
885
 
759
886
  const allOptions = R.propOr([], 'options', product);
760
- const allQuestions = R.call(R.compose(
761
- R.uniqBy(R.prop('id')),
762
- R.chain(obj => (obj.questions || []).map(q => ({
763
- ...q,
764
- isPerUnitItem: Boolean(obj.isPerUnitItem),
765
- }))),
766
- R.filter(o => o.questions && o.questions.length),
767
- ), [
887
+ const allQuestions = collectQuestions([
768
888
  ...allOptions,
769
- ...R.chain(R.propOr([], 'units'), allOptions).map(o => ({ ...o, isPerUnitItem: true })),
889
+ ...R.chain(R.propOr([], 'units'), allOptions).map(u => ({
890
+ ...u,
891
+ isPerUnitItem: true,
892
+ unitId: u.id,
893
+ })),
770
894
  ]);
771
895
  if (!product) return { fields: [] };
772
896
  return {
package/index.test.js CHANGED
@@ -3,6 +3,7 @@ const R = require('ramda');
3
3
  const moment = require('moment');
4
4
  const faker = require('faker');
5
5
  const axios = require('axios');
6
+ const jwt = require('jsonwebtoken');
6
7
 
7
8
  const Plugin = require('./index');
8
9
 
@@ -29,8 +30,9 @@ const app = new Plugin({
29
30
  jwtKey: process.env.ti2_ventrata_jwtKey,
30
31
  endpoint: process.env.ti2_ventrata_endpoint,
31
32
  });
33
+ const describeLive = process.env.RUN_VENTRATA_LIVE_TESTS === 'true' ? describe : describe.skip;
32
34
 
33
- describe('search tests', () => {
35
+ describeLive('search tests', () => {
34
36
  let products;
35
37
  let testProduct = {
36
38
  productName: 'Edinburgh 3 Day Pass',
@@ -336,3 +338,49 @@ describe('search tests', () => {
336
338
  });
337
339
  });
338
340
  });
341
+
342
+ describe('createBooking error handling', () => {
343
+ it('surfaces provider error message and preserves status details', async () => {
344
+ const plugin = new Plugin({
345
+ jwtKey: 'ventrata-test-jwt-key',
346
+ endpoint: 'https://api.ventrata.com/octo',
347
+ });
348
+ const providerError = {
349
+ response: {
350
+ status: 400,
351
+ data: {
352
+ errorMessage: 'The phone number Collect is invalid.',
353
+ },
354
+ },
355
+ message: 'Request failed with status code 400',
356
+ };
357
+ const mockedAxios = jest.fn().mockRejectedValueOnce(providerError);
358
+
359
+ const availabilityKey = jwt.sign({
360
+ settlementMethods: ['DEFERRED'],
361
+ unitItems: [],
362
+ }, 'ventrata-test-jwt-key');
363
+
364
+ try {
365
+ await plugin.createBooking({
366
+ axios: mockedAxios,
367
+ token: {
368
+ apiKey: 'test-api-key',
369
+ endpoint: 'https://api.ventrata.com/octo',
370
+ },
371
+ payload: {
372
+ availabilityKey,
373
+ holder: {
374
+ name: 'Test',
375
+ surname: 'User',
376
+ },
377
+ },
378
+ typeDefsAndQueries,
379
+ });
380
+ throw new Error('Expected createBooking to throw');
381
+ } catch (error) {
382
+ expect(error.message).toBe('The phone number Collect is invalid.');
383
+ expect(error.response.status).toBe(400);
384
+ }
385
+ });
386
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-ventrata",
3
- "version": "1.0.40",
3
+ "version": "1.0.45",
4
4
  "description": "Ventrata's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -46,6 +46,6 @@
46
46
  "jest-diff": "^27.4.2",
47
47
  "jest-environment-node": "27.4",
48
48
  "jest-util": "27.4",
49
- "ti2": "latest"
49
+ "ti2": "file:../ti2"
50
50
  }
51
51
  }
@@ -49,7 +49,10 @@ const resolvers = {
49
49
  pickupPoints: root => R.pathOr([], ['pickupPoints'], root)
50
50
  .map(o => ({
51
51
  ...o,
52
- postal: o.postal_code,
52
+ postal: o.postal || o.postalCode || o.postal_code || null,
53
+ city: o.city || o.locality || null,
54
+ state: o.state || o.region || null,
55
+ localDateTime: o.localDateTime || o.localDateTimeStart || null,
53
56
  })),
54
57
  },
55
58
  };
@@ -2,6 +2,30 @@ const { makeExecutableSchema } = require('@graphql-tools/schema');
2
2
  const R = require('ramda');
3
3
  const { graphql } = require('graphql');
4
4
 
5
+ const isHttpUrl = value => typeof value === 'string' && /^https?:\/\//i.test(value);
6
+
7
+ const getFirstHttpDeliveryUrl = root => {
8
+ const voucherDeliveryOptions = R.pathOr([], ['voucher', 'deliveryOptions'], root);
9
+ const ticketDeliveryOptions = R.chain(
10
+ item => R.pathOr([], ['ticket', 'deliveryOptions'], item),
11
+ R.pathOr([], ['unitItems'], root),
12
+ );
13
+ return R.call(
14
+ R.compose(
15
+ R.find(isHttpUrl),
16
+ R.map(R.prop('deliveryValue')),
17
+ R.flatten,
18
+ ),
19
+ [voucherDeliveryOptions, ticketDeliveryOptions],
20
+ ) || null;
21
+ };
22
+
23
+ const getBookingUrl = root => (
24
+ R.path(['checkinUrl'], root)
25
+ || getFirstHttpDeliveryUrl(root)
26
+ || null
27
+ );
28
+
5
29
  const capitalize = sParam => {
6
30
  if (typeof sParam !== 'string') return '';
7
31
  const s = sParam.replace(/_/g, ' ');
@@ -49,8 +73,8 @@ const resolvers = {
49
73
  },
50
74
  optionId: R.path(['option', 'id']),
51
75
  optionName: root => R.path(['option', 'title'], root) || R.path(['option', 'internalName'], root),
52
- publicUrl: () => null,
53
- privateUrl: () => null,
76
+ publicUrl: getBookingUrl,
77
+ privateUrl: getBookingUrl,
54
78
  pickupRequested: R.prop('pickupRequested'),
55
79
  pickupPointId: R.prop('pickupPointId'),
56
80
  pickupPoint: root => {
@@ -58,7 +82,10 @@ const resolvers = {
58
82
  if (!pickupPoint) return null;
59
83
  return {
60
84
  ...pickupPoint,
61
- postal: pickupPoint.postal_code,
85
+ postal: pickupPoint.postal || pickupPoint.postalCode || pickupPoint.postal_code || null,
86
+ city: pickupPoint.city || pickupPoint.locality || null,
87
+ state: pickupPoint.state || pickupPoint.region || null,
88
+ localDateTime: pickupPoint.localDateTime || pickupPoint.localDateTimeStart || null,
62
89
  };
63
90
  },
64
91
  },
@@ -0,0 +1,428 @@
1
+ const jwt = require('jsonwebtoken');
2
+
3
+ const Plugin = require('../index');
4
+ const { translateAvailability } = require('./availability');
5
+ const { translateBooking } = require('./booking');
6
+ const { typeDefs: ti2BookingTypeDefs, query: ti2BookingQuery } = require('../node_modules/ti2/controllers/graphql-schemas/booking');
7
+ const { typeDefs: ti2AvailabilityTypeDefs, query: ti2AvailabilityQuery } = require('../node_modules/ti2/controllers/graphql-schemas/availability');
8
+
9
+ describe('ventrata mappings', () => {
10
+ afterEach(() => {
11
+ jest.restoreAllMocks();
12
+ });
13
+
14
+ it('keeps unit-scoped duplicate question ids in create booking fields', async () => {
15
+ const plugin = new Plugin({
16
+ jwtKey: 'test-jwt-key',
17
+ endpoint: 'https://example.test/octo',
18
+ });
19
+ const axios = jest.fn().mockResolvedValue({
20
+ data: {
21
+ options: [
22
+ {
23
+ id: 'DEFAULT',
24
+ questions: [],
25
+ units: [
26
+ {
27
+ id: 'adult-unit',
28
+ questions: [{ id: 'meal-choice', label: 'Meal', inputType: 'select', selectOptions: [] }],
29
+ },
30
+ {
31
+ id: 'child-unit',
32
+ questions: [{ id: 'meal-choice', label: 'Meal', inputType: 'select', selectOptions: [] }],
33
+ },
34
+ ],
35
+ },
36
+ ],
37
+ },
38
+ });
39
+
40
+ const result = await plugin.getCreateBookingFields({
41
+ axios,
42
+ token: { apiKey: 'api-key' },
43
+ query: { productId: 'product-1' },
44
+ });
45
+
46
+ expect(result.customFields).toEqual(
47
+ expect.arrayContaining([
48
+ expect.objectContaining({
49
+ id: 'meal-choice',
50
+ isPerUnitItem: true,
51
+ unitId: 'adult-unit',
52
+ required: false,
53
+ requiredPerParticipant: false,
54
+ requiredPerBooking: false,
55
+ visiblePerParticipant: true,
56
+ visiblePerBooking: true,
57
+ }),
58
+ expect.objectContaining({
59
+ id: 'meal-choice',
60
+ isPerUnitItem: true,
61
+ unitId: 'child-unit',
62
+ required: false,
63
+ requiredPerParticipant: false,
64
+ requiredPerBooking: false,
65
+ visiblePerParticipant: true,
66
+ visiblePerBooking: true,
67
+ }),
68
+ ]),
69
+ );
70
+ });
71
+
72
+ it('maps mandatory flags for booking-level and per-participant questions', async () => {
73
+ const plugin = new Plugin({
74
+ jwtKey: 'test-jwt-key',
75
+ endpoint: 'https://example.test/octo',
76
+ });
77
+ const axios = jest.fn().mockResolvedValue({
78
+ data: {
79
+ options: [
80
+ {
81
+ id: 'DEFAULT',
82
+ questions: [{ id: 'hotel', label: 'Hotel', inputType: 'text', required: true }],
83
+ units: [
84
+ {
85
+ id: 'adult-unit',
86
+ questions: [{ id: 'meal-choice', label: 'Meal', inputType: 'select', required: true, selectOptions: [] }],
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ });
93
+
94
+ const result = await plugin.getCreateBookingFields({
95
+ axios,
96
+ token: { apiKey: 'api-key' },
97
+ query: { productId: 'product-1' },
98
+ });
99
+
100
+ expect(result.customFields).toEqual(
101
+ expect.arrayContaining([
102
+ expect.objectContaining({
103
+ id: 'hotel',
104
+ required: true,
105
+ requiredPerBooking: true,
106
+ requiredPerParticipant: false,
107
+ visiblePerBooking: true,
108
+ visiblePerParticipant: false,
109
+ }),
110
+ expect.objectContaining({
111
+ id: 'meal-choice',
112
+ unitId: 'adult-unit',
113
+ required: true,
114
+ requiredPerBooking: true,
115
+ requiredPerParticipant: true,
116
+ visiblePerBooking: true,
117
+ visiblePerParticipant: true,
118
+ }),
119
+ ]),
120
+ );
121
+ });
122
+
123
+ it('accepts pickupPointId alias when creating booking', async () => {
124
+ const plugin = new Plugin({
125
+ jwtKey: 'test-jwt-key',
126
+ endpoint: 'https://example.test/octo',
127
+ });
128
+
129
+ const verifySpy = jest.spyOn(jwt, 'verify').mockReturnValue({
130
+ productId: 'product-1',
131
+ optionId: 'DEFAULT',
132
+ availabilityId: 'avail-1',
133
+ unitItems: [{ unitId: 'adult-unit' }],
134
+ settlementMethods: ['DEFERRED'],
135
+ });
136
+
137
+ const axios = jest.fn().mockResolvedValue({
138
+ data: {
139
+ id: 'booking-id',
140
+ orderId: 'order-id',
141
+ orderReference: 'order-ref',
142
+ supplierReference: 'supplier-ref',
143
+ status: 'CONFIRMED',
144
+ product: { id: 'product-1', internalName: 'Product' },
145
+ option: { id: 'DEFAULT', internalName: 'DEFAULT' },
146
+ availability: { localDateTimeStart: '2026-05-02T08:00:00', localDateTimeEnd: '2026-05-02T10:00:00' },
147
+ contact: { fullName: 'Test User' },
148
+ cancellable: true,
149
+ utcCreatedAt: '2026-05-01T00:00:00Z',
150
+ pricing: { retail: 1000 },
151
+ unitItems: [{ uuid: 'unit-item-1', unitId: 'adult-unit', internalName: 'Adult' }],
152
+ pickupRequested: true,
153
+ pickupPointId: 'pickup-123',
154
+ utcConfirmedAt: '2026-05-01T00:00:01Z',
155
+ },
156
+ });
157
+
158
+ await plugin.createBooking({
159
+ axios,
160
+ token: { apiKey: 'api-key' },
161
+ payload: {
162
+ availabilityKey: 'signed-key',
163
+ holder: { name: 'Test', surname: 'User', country: 'US' },
164
+ pickupPointId: 'pickup-123',
165
+ },
166
+ typeDefsAndQueries: {
167
+ bookingTypeDefs: ti2BookingTypeDefs,
168
+ bookingQuery: ti2BookingQuery,
169
+ },
170
+ });
171
+
172
+ expect(verifySpy).toHaveBeenCalledWith('signed-key', 'test-jwt-key');
173
+ const createBookingCall = axios.mock.calls.find(call => call[0].url === 'https://example.test/octo/bookings');
174
+ expect(createBookingCall).toBeTruthy();
175
+ expect(createBookingCall[0].data).toEqual(
176
+ expect.objectContaining({
177
+ pickupRequested: true,
178
+ pickupPointId: 'pickup-123',
179
+ }),
180
+ );
181
+ });
182
+
183
+ it('maps participant-scoped custom fields from standard ti2 payload into unitItems questionAnswers', async () => {
184
+ const plugin = new Plugin({
185
+ jwtKey: 'test-jwt-key',
186
+ endpoint: 'https://example.test/octo',
187
+ });
188
+
189
+ jest.spyOn(jwt, 'verify').mockReturnValue({
190
+ productId: 'product-1',
191
+ optionId: 'DEFAULT',
192
+ availabilityId: 'avail-1',
193
+ unitItems: [
194
+ { unitId: 'adult-unit' },
195
+ { unitId: 'adult-unit' },
196
+ ],
197
+ settlementMethods: ['DEFERRED'],
198
+ });
199
+
200
+ const axios = jest.fn().mockResolvedValue({
201
+ data: {
202
+ id: 'booking-id',
203
+ orderId: 'order-id',
204
+ orderReference: 'order-ref',
205
+ supplierReference: 'supplier-ref',
206
+ status: 'CONFIRMED',
207
+ product: { id: 'product-1', internalName: 'Product' },
208
+ option: { id: 'DEFAULT', internalName: 'DEFAULT' },
209
+ availability: { localDateTimeStart: '2026-05-02T08:00:00', localDateTimeEnd: '2026-05-02T10:00:00' },
210
+ contact: { fullName: 'Test User' },
211
+ cancellable: true,
212
+ utcCreatedAt: '2026-05-01T00:00:00Z',
213
+ pricing: { retail: 1000 },
214
+ unitItems: [{ uuid: 'unit-item-1', unitId: 'adult-unit', internalName: 'Adult' }],
215
+ utcConfirmedAt: '2026-05-01T00:00:01Z',
216
+ },
217
+ });
218
+
219
+ await plugin.createBooking({
220
+ axios,
221
+ token: { apiKey: 'api-key' },
222
+ payload: {
223
+ availabilityKey: 'signed-key',
224
+ holder: { name: 'Test', surname: 'User', country: 'US' },
225
+ customFieldValues: [
226
+ {
227
+ field: { id: 'specialReq', isPerUnitItem: false },
228
+ value: 'Window seat',
229
+ },
230
+ ],
231
+ participants: [
232
+ {
233
+ fields: [
234
+ {
235
+ field: { id: 'dietary', isPerUnitItem: true, unitId: 'adult-unit' },
236
+ value: 'Vegan',
237
+ },
238
+ ],
239
+ },
240
+ {
241
+ fields: [
242
+ {
243
+ field: { id: 'dietary', isPerUnitItem: true, unitId: 'adult-unit' },
244
+ value: 'Gluten-free',
245
+ },
246
+ ],
247
+ },
248
+ ],
249
+ },
250
+ typeDefsAndQueries: {
251
+ bookingTypeDefs: ti2BookingTypeDefs,
252
+ bookingQuery: ti2BookingQuery,
253
+ },
254
+ });
255
+
256
+ const createBookingCall = axios.mock.calls.find(call => call[0].url === 'https://example.test/octo/bookings');
257
+ expect(createBookingCall).toBeTruthy();
258
+ expect(createBookingCall[0].data.questionAnswers).toEqual(
259
+ expect.arrayContaining([
260
+ {
261
+ questionId: 'specialReq',
262
+ value: 'Window seat',
263
+ },
264
+ ]),
265
+ );
266
+ expect(createBookingCall[0].data.unitItems).toEqual([
267
+ {
268
+ unitId: 'adult-unit',
269
+ questionAnswers: [
270
+ {
271
+ questionId: 'dietary',
272
+ value: 'Vegan',
273
+ },
274
+ ],
275
+ },
276
+ {
277
+ unitId: 'adult-unit',
278
+ questionAnswers: [
279
+ {
280
+ questionId: 'dietary',
281
+ value: 'Gluten-free',
282
+ },
283
+ ],
284
+ },
285
+ ]);
286
+ });
287
+
288
+ it('normalizes object-like custom-field values to scalar questionAnswers values', async () => {
289
+ const plugin = new Plugin({
290
+ jwtKey: 'test-jwt-key',
291
+ endpoint: 'https://example.test/octo',
292
+ });
293
+
294
+ jest.spyOn(jwt, 'verify').mockReturnValue({
295
+ productId: 'product-1',
296
+ optionId: 'DEFAULT',
297
+ availabilityId: 'avail-1',
298
+ unitItems: [{ unitId: 'adult-unit' }],
299
+ settlementMethods: ['DEFERRED'],
300
+ });
301
+
302
+ const axios = jest.fn().mockResolvedValue({
303
+ data: {
304
+ id: 'booking-id',
305
+ orderId: 'order-id',
306
+ orderReference: 'order-ref',
307
+ supplierReference: 'supplier-ref',
308
+ status: 'CONFIRMED',
309
+ product: { id: 'product-1', internalName: 'Product' },
310
+ option: { id: 'DEFAULT', internalName: 'DEFAULT' },
311
+ availability: { localDateTimeStart: '2026-05-02T08:00:00', localDateTimeEnd: '2026-05-02T10:00:00' },
312
+ contact: { fullName: 'Test User' },
313
+ cancellable: true,
314
+ utcCreatedAt: '2026-05-01T00:00:00Z',
315
+ pricing: { retail: 1000 },
316
+ unitItems: [{ uuid: 'unit-item-1', unitId: 'adult-unit', internalName: 'Adult' }],
317
+ utcConfirmedAt: '2026-05-01T00:00:01Z',
318
+ },
319
+ });
320
+
321
+ const lunchSelection = 'Green Salad (Gluten Free) - romaine lettuce diced tomato green onion cucumber diced chicken & french dressing';
322
+
323
+ await plugin.createBooking({
324
+ axios,
325
+ token: { apiKey: 'api-key' },
326
+ payload: {
327
+ availabilityKey: 'signed-key',
328
+ holder: { name: 'Test', surname: 'User', country: 'US' },
329
+ customFieldValues: [
330
+ {
331
+ field: { id: '8959d9a0-cb46-4f29-97fa-e07ce7f961da', isPerUnitItem: false },
332
+ value: {
333
+ label: lunchSelection,
334
+ value: lunchSelection,
335
+ userInput: true,
336
+ },
337
+ },
338
+ ],
339
+ },
340
+ typeDefsAndQueries: {
341
+ bookingTypeDefs: ti2BookingTypeDefs,
342
+ bookingQuery: ti2BookingQuery,
343
+ },
344
+ });
345
+
346
+ const createBookingCall = axios.mock.calls.find(call => call[0].url === 'https://example.test/octo/bookings');
347
+ expect(createBookingCall).toBeTruthy();
348
+ expect(createBookingCall[0].data.questionAnswers).toEqual(
349
+ expect.arrayContaining([
350
+ {
351
+ questionId: '8959d9a0-cb46-4f29-97fa-e07ce7f961da',
352
+ value: lunchSelection,
353
+ },
354
+ ]),
355
+ );
356
+ });
357
+
358
+ it('normalizes pickup point fields for availability and booking translations', async () => {
359
+ const availability = await translateAvailability({
360
+ rootValue: {
361
+ pickupPoints: [
362
+ {
363
+ id: 'pickup-1',
364
+ postalCode: 'T1L',
365
+ locality: 'Banff',
366
+ region: 'Alberta',
367
+ localDateTimeStart: '2026-05-02T08:00:00',
368
+ },
369
+ ],
370
+ },
371
+ variableValues: {
372
+ productId: 'product-1',
373
+ optionId: 'DEFAULT',
374
+ unitsWithQuantity: [],
375
+ currency: 'CAD',
376
+ jwtKey: '',
377
+ },
378
+ typeDefs: ti2AvailabilityTypeDefs,
379
+ query: ti2AvailabilityQuery,
380
+ });
381
+ expect(availability.pickupPoints[0]).toEqual(expect.objectContaining({
382
+ postal: 'T1L',
383
+ city: 'Banff',
384
+ state: 'Alberta',
385
+ localDateTime: '2026-05-02T08:00:00',
386
+ }));
387
+
388
+ const booking = await translateBooking({
389
+ rootValue: {
390
+ id: 'booking-2',
391
+ status: 'CONFIRMED',
392
+ unitItems: [],
393
+ option: {
394
+ id: 'DEFAULT',
395
+ internalName: 'DEFAULT',
396
+ cancellationCutoff: '',
397
+ },
398
+ product: { id: 'product-1', internalName: 'Product' },
399
+ availability: {},
400
+ contact: {},
401
+ pricing: {},
402
+ voucher: {
403
+ deliveryOptions: [
404
+ {
405
+ deliveryFormat: 'PDF_URL',
406
+ deliveryValue: 'https://api.ventrata.com/octo/pdf?booking=booking-2',
407
+ },
408
+ ],
409
+ },
410
+ pickupPoint: {
411
+ id: 'pickup-2',
412
+ postalCode: 'T2E 7Y5',
413
+ locality: 'Calgary',
414
+ region: 'Alberta',
415
+ },
416
+ },
417
+ typeDefs: ti2BookingTypeDefs,
418
+ query: ti2BookingQuery,
419
+ });
420
+ expect(booking.pickupPoint).toEqual(expect.objectContaining({
421
+ postal: 'T2E 7Y5',
422
+ city: 'Calgary',
423
+ state: 'Alberta',
424
+ }));
425
+ expect(booking.publicUrl).toBe('https://api.ventrata.com/octo/pdf?booking=booking-2');
426
+ expect(booking.privateUrl).toBe('https://api.ventrata.com/octo/pdf?booking=booking-2');
427
+ });
428
+ });