ti2-ventrata 1.0.39 → 1.0.42

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,51 +433,137 @@ 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;
437
503
  }
438
-
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;
540
+ }
541
+ const { settlementMethods } = dataForCreateBooking;
439
542
  const settlementMethod = Plugin.getSettlementMethod(
440
543
  reference,
441
- dataForCreateBooking.settlementMethods,
544
+ settlementMethods,
442
545
  );
443
- let booking = R.path(['data'], await axios({
444
- method: rebookingId ? 'patch' : 'post',
445
- url: `${endpoint || this.endpoint}/bookings${rebookingId ? `/${rebookingId}` : ''}`,
446
- data: {
447
- orderId,
448
- settlementMethod,
449
- ...dataForCreateBooking,
450
- notes,
451
- ...(pickupPoint ? { pickupRequested: true, pickupPointId: pickupPoint } : {}),
452
- },
453
- headers,
454
- }));
546
+
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
+ }
455
567
  // for booking update, we may not need to confirm again
456
568
  if (!booking.utcConfirmedAt && !partialOrder) {
457
569
  const validatedCountry = validateCountry(R.pathOr('', ['country'], holder));
@@ -499,7 +611,7 @@ class Plugin {
499
611
  }));
500
612
  } catch (error) {
501
613
  console.error('Booking confirmation failed:', (error.response && error.response.data) ? error.response.data : error.message);
502
- throw error;
614
+ throwWithVentrataErrorMessage(error, 'Failed to confirm booking');
503
615
  }
504
616
  }
505
617
  return ({
@@ -714,8 +826,14 @@ class Plugin {
714
826
  });
715
827
  const convertQToField = o => ({
716
828
  id: o.id,
829
+ ...(o.unitId ? { unitId: o.unitId } : {}),
717
830
  subtitle: o.description === o.label ? '' : o.description,
718
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),
719
837
  type: (() => {
720
838
  if (o.inputType === 'radio') return 'radio';
721
839
  if (Array.isArray(o.selectOptions) && o.selectOptions.length) return 'extended-option';
@@ -727,6 +845,19 @@ class Plugin {
727
845
  options: o.selectOptions,
728
846
  isPerUnitItem: o.isPerUnitItem,
729
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);
730
861
  if (!productId) {
731
862
  const allProducts = R.pathOr([], ['data'], await axios({
732
863
  method: 'get',
@@ -734,15 +865,12 @@ class Plugin {
734
865
  headers,
735
866
  }));
736
867
  const allOptions = R.chain(R.propOr([], 'options'), allProducts);
737
- const allUnits = R.chain(R.propOr([], 'units'), allOptions).map(o => ({ ...o, isPerUnitItem: true }));
738
- const allQuestions = R.call(R.compose(
739
- R.uniqBy(R.prop('id')),
740
- R.chain(obj => (obj.questions || []).map(q => ({
741
- ...q,
742
- isPerUnitItem: Boolean(obj.isPerUnitItem),
743
- }))),
744
- R.filter(o => o.questions && o.questions.length),
745
- ), [...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]);
746
874
  return {
747
875
  fields: [],
748
876
  customFields: allQuestions.map(convertQToField),
@@ -756,16 +884,13 @@ class Plugin {
756
884
  }));
757
885
 
758
886
  const allOptions = R.propOr([], 'options', product);
759
- const allQuestions = R.call(R.compose(
760
- R.uniqBy(R.prop('id')),
761
- R.chain(obj => (obj.questions || []).map(q => ({
762
- ...q,
763
- isPerUnitItem: Boolean(obj.isPerUnitItem),
764
- }))),
765
- R.filter(o => o.questions && o.questions.length),
766
- ), [
887
+ const allQuestions = collectQuestions([
767
888
  ...allOptions,
768
- ...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
+ })),
769
894
  ]);
770
895
  if (!product) return { fields: [] };
771
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.39",
3
+ "version": "1.0.42",
4
4
  "description": "Ventrata's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
+ });