ti2-ventrata 1.0.40 → 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 +180 -56
- 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,33 +433,110 @@ 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;
|
|
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
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
614
|
+
throwWithVentrataErrorMessage(error, 'Failed to confirm booking');
|
|
504
615
|
}
|
|
505
616
|
}
|
|
506
617
|
return ({
|
|
@@ -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(
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|