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 +183 -58
- package/index.test.js +49 -1
- package/package.json +1 -1
- package/resolvers/availability.js +4 -1
- package/resolvers/booking.js +30 -3
- package/resolvers/mapping.test.js +428 -0
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
544
|
+
settlementMethods,
|
|
442
545
|
);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
data
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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(
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -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
|
};
|
package/resolvers/booking.js
CHANGED
|
@@ -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:
|
|
53
|
-
privateUrl:
|
|
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
|
+
});
|