ti2 1.0.116 → 1.0.119
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/README.md +1 -0
- package/__tests__/cache.js +40 -0
- package/api.yml +112 -0
- package/cache.js +35 -0
- package/controllers/__tests__/bookings-searchProducts.js +192 -5
- package/controllers/__tests__/bookings.js +53 -0
- package/controllers/bookings.js +238 -43
- package/index.js +2 -0
- package/package.json +1 -1
- package/test/plugin.js +41 -0
- package/tutorials/plugin-development.md +2 -0
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Plugins are the connectores to other systems and/or features you intent to use;
|
|
|
56
56
|
|searchAvailability|✓|✓|✓|✓|✓|✓|
|
|
57
57
|
|searchQuote|✓|✓|✓|✓|✓|✓|
|
|
58
58
|
|createBooking|✓|✓|✓|✓|✓|✓|
|
|
59
|
+
|confirmBooking|✓|✓|✓|✓|✓|✓|
|
|
59
60
|
|cancelBooking|✓|✓|✓|✓|✓|✓|
|
|
60
61
|
|searchProductsForItinerary|||||||||✓|
|
|
61
62
|
|searchAvailabilityForItinerary|||||||||✓|
|
package/__tests__/cache.js
CHANGED
|
@@ -39,6 +39,46 @@ describe('cache', () => {
|
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
describe('owner-token helpers', () => {
|
|
43
|
+
it('should only renew and drop a key when the expected value still owns it', async () => {
|
|
44
|
+
const key = 'owner-lock:test';
|
|
45
|
+
testKeys.push(key);
|
|
46
|
+
|
|
47
|
+
await cache.save({ pluginName: testPluginName, key, value: 'owner-a', ttl: 1 });
|
|
48
|
+
|
|
49
|
+
const renewedByWrongOwner = await cache.expireIfValue({
|
|
50
|
+
pluginName: testPluginName,
|
|
51
|
+
key,
|
|
52
|
+
value: 'owner-b',
|
|
53
|
+
ttl: 60,
|
|
54
|
+
});
|
|
55
|
+
const renewedByOwner = await cache.expireIfValue({
|
|
56
|
+
pluginName: testPluginName,
|
|
57
|
+
key,
|
|
58
|
+
value: 'owner-a',
|
|
59
|
+
ttl: 60,
|
|
60
|
+
});
|
|
61
|
+
const droppedByWrongOwner = await cache.dropIfValue({
|
|
62
|
+
pluginName: testPluginName,
|
|
63
|
+
key,
|
|
64
|
+
value: 'owner-b',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(renewedByWrongOwner).toBe(false);
|
|
68
|
+
expect(renewedByOwner).toBe(true);
|
|
69
|
+
expect(droppedByWrongOwner).toBe(false);
|
|
70
|
+
expect(await cache.get({ pluginName: testPluginName, key })).toBe('owner-a');
|
|
71
|
+
|
|
72
|
+
const droppedByOwner = await cache.dropIfValue({
|
|
73
|
+
pluginName: testPluginName,
|
|
74
|
+
key,
|
|
75
|
+
value: 'owner-a',
|
|
76
|
+
});
|
|
77
|
+
expect(droppedByOwner).toBe(true);
|
|
78
|
+
expect(await cache.get({ pluginName: testPluginName, key })).toBeFalsy();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
42
82
|
describe('scan', () => {
|
|
43
83
|
it('should return keys matching the pattern without plugin prefix', async () => {
|
|
44
84
|
// Save some test keys
|
package/api.yml
CHANGED
|
@@ -358,6 +358,39 @@ components:
|
|
|
358
358
|
type: object
|
|
359
359
|
allOf: # Combines the BasicErrorModel and the inline model
|
|
360
360
|
- $ref: '#/components/schemas/bookingDetail'
|
|
361
|
+
bookingDetailsForConfirm:
|
|
362
|
+
type: object
|
|
363
|
+
additionalProperties: true
|
|
364
|
+
description: Booking identifier; provide bookingId, id, ref, or reference
|
|
365
|
+
properties:
|
|
366
|
+
bookingId:
|
|
367
|
+
type: string
|
|
368
|
+
id:
|
|
369
|
+
type: string
|
|
370
|
+
ref:
|
|
371
|
+
type: string
|
|
372
|
+
reference:
|
|
373
|
+
type: string
|
|
374
|
+
confirmBookingReturn:
|
|
375
|
+
type: object
|
|
376
|
+
additionalProperties: true
|
|
377
|
+
properties:
|
|
378
|
+
confirmBookingReply:
|
|
379
|
+
type: object
|
|
380
|
+
booking:
|
|
381
|
+
type: object
|
|
382
|
+
properties:
|
|
383
|
+
ref:
|
|
384
|
+
type: string
|
|
385
|
+
id:
|
|
386
|
+
type: string
|
|
387
|
+
status:
|
|
388
|
+
type: string
|
|
389
|
+
serviceLines:
|
|
390
|
+
type: array
|
|
391
|
+
items:
|
|
392
|
+
type: object
|
|
393
|
+
additionalProperties: true
|
|
361
394
|
packageSearchQuery:
|
|
362
395
|
type: object
|
|
363
396
|
additionalProperties: true
|
|
@@ -1815,6 +1848,85 @@ paths:
|
|
|
1815
1848
|
application/json:
|
|
1816
1849
|
schema:
|
|
1817
1850
|
$ref: '#/components/schemas/cancellationReturn'
|
|
1851
|
+
/bookings/{appKey}/{userId}/confirm:
|
|
1852
|
+
post:
|
|
1853
|
+
security:
|
|
1854
|
+
- bearerAuth: ['admin', 'user']
|
|
1855
|
+
tags:
|
|
1856
|
+
- bookings
|
|
1857
|
+
summary: Confirms a quote as a booking
|
|
1858
|
+
operationId: confirmBooking
|
|
1859
|
+
parameters:
|
|
1860
|
+
- name: appKey
|
|
1861
|
+
in: path
|
|
1862
|
+
description: app to confirm the booking for, defaults to first token
|
|
1863
|
+
required: true
|
|
1864
|
+
example: 'travelgate-hotelx'
|
|
1865
|
+
schema:
|
|
1866
|
+
type: string
|
|
1867
|
+
- name: userId
|
|
1868
|
+
in: path
|
|
1869
|
+
description: the user/company that the appKey belongs to
|
|
1870
|
+
required: true
|
|
1871
|
+
schema:
|
|
1872
|
+
type: string
|
|
1873
|
+
requestBody:
|
|
1874
|
+
description: Booking identifier to confirm
|
|
1875
|
+
required: true
|
|
1876
|
+
content:
|
|
1877
|
+
application/json:
|
|
1878
|
+
schema:
|
|
1879
|
+
$ref: '#/components/schemas/bookingDetailsForConfirm'
|
|
1880
|
+
responses:
|
|
1881
|
+
'200':
|
|
1882
|
+
description: Booking confirmation details
|
|
1883
|
+
content:
|
|
1884
|
+
application/json:
|
|
1885
|
+
schema:
|
|
1886
|
+
$ref: '#/components/schemas/confirmBookingReturn'
|
|
1887
|
+
/bookings/{appKey}/{userId}/{hint}/confirm:
|
|
1888
|
+
post:
|
|
1889
|
+
security:
|
|
1890
|
+
- bearerAuth: ['admin', 'user']
|
|
1891
|
+
tags:
|
|
1892
|
+
- bookings
|
|
1893
|
+
summary: Confirms a quote as a booking for a specific hint
|
|
1894
|
+
operationId: confirmBooking
|
|
1895
|
+
parameters:
|
|
1896
|
+
- name: appKey
|
|
1897
|
+
in: path
|
|
1898
|
+
description: app to confirm the booking for
|
|
1899
|
+
required: true
|
|
1900
|
+
example: 'travelgate'
|
|
1901
|
+
schema:
|
|
1902
|
+
type: string
|
|
1903
|
+
- name: userId
|
|
1904
|
+
in: path
|
|
1905
|
+
description: the user/company that the appKey belongs to
|
|
1906
|
+
required: true
|
|
1907
|
+
schema:
|
|
1908
|
+
type: string
|
|
1909
|
+
- name: hint
|
|
1910
|
+
in: path
|
|
1911
|
+
description: a specific token hint to be used
|
|
1912
|
+
required: true
|
|
1913
|
+
example: 'travelgate-australia'
|
|
1914
|
+
schema:
|
|
1915
|
+
type: string
|
|
1916
|
+
requestBody:
|
|
1917
|
+
description: Booking identifier to confirm
|
|
1918
|
+
required: true
|
|
1919
|
+
content:
|
|
1920
|
+
application/json:
|
|
1921
|
+
schema:
|
|
1922
|
+
$ref: '#/components/schemas/bookingDetailsForConfirm'
|
|
1923
|
+
responses:
|
|
1924
|
+
'200':
|
|
1925
|
+
description: Booking confirmation details
|
|
1926
|
+
content:
|
|
1927
|
+
application/json:
|
|
1928
|
+
schema:
|
|
1929
|
+
$ref: '#/components/schemas/confirmBookingReturn'
|
|
1818
1930
|
/packages/{appKey}/{userId}/search:
|
|
1819
1931
|
post:
|
|
1820
1932
|
security:
|
package/cache.js
CHANGED
|
@@ -48,6 +48,39 @@ const saveIfNotExists = async ({
|
|
|
48
48
|
return result === 'OK';
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
const expireIfValue = async ({
|
|
52
|
+
pluginName,
|
|
53
|
+
key: keyParam,
|
|
54
|
+
value,
|
|
55
|
+
ttl = defaultTTL,
|
|
56
|
+
}) => {
|
|
57
|
+
const key = buildKey({ pluginName, key: keyParam });
|
|
58
|
+
const expectedValue = JSON.stringify(value);
|
|
59
|
+
const result = await cache.eval(`
|
|
60
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
61
|
+
return redis.call('expire', KEYS[1], ARGV[2])
|
|
62
|
+
end
|
|
63
|
+
return 0
|
|
64
|
+
`, 1, key, expectedValue, ttl);
|
|
65
|
+
return result === 1;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const dropIfValue = async ({
|
|
69
|
+
pluginName,
|
|
70
|
+
key: keyParam,
|
|
71
|
+
value,
|
|
72
|
+
}) => {
|
|
73
|
+
const key = buildKey({ pluginName, key: keyParam });
|
|
74
|
+
const expectedValue = JSON.stringify(value);
|
|
75
|
+
const result = await cache.eval(`
|
|
76
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
77
|
+
return redis.call('del', KEYS[1])
|
|
78
|
+
end
|
|
79
|
+
return 0
|
|
80
|
+
`, 1, key, expectedValue);
|
|
81
|
+
return result === 1;
|
|
82
|
+
};
|
|
83
|
+
|
|
51
84
|
const get = async ({
|
|
52
85
|
pluginName,
|
|
53
86
|
key: keyParam,
|
|
@@ -125,6 +158,8 @@ module.exports = {
|
|
|
125
158
|
cache,
|
|
126
159
|
save,
|
|
127
160
|
saveIfNotExists,
|
|
161
|
+
expireIfValue,
|
|
162
|
+
dropIfValue,
|
|
128
163
|
getOrExec,
|
|
129
164
|
drop,
|
|
130
165
|
get,
|
|
@@ -9,7 +9,7 @@ const cache = require('../../cache');
|
|
|
9
9
|
// Mock the worker/queue module
|
|
10
10
|
jest.mock('../../worker/queue', () => ({
|
|
11
11
|
...jest.requireActual('../../worker/queue'), // Import and retain default behavior
|
|
12
|
-
addJob: jest.fn().mockResolvedValue(
|
|
12
|
+
addJob: jest.fn().mockResolvedValue('mockJobId'), // Mock addJob
|
|
13
13
|
// Provide mock for jobStatus as it might be called by other parts of the code or tests
|
|
14
14
|
jobStatus: jest.fn().mockResolvedValue({ status: 'completed', progress: 100, returnValue: null }),
|
|
15
15
|
}));
|
|
@@ -160,6 +160,64 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
160
160
|
expect(products[0].options.length).toBe(1);
|
|
161
161
|
expect(products[0].options[0].optionName).toBe('Transfer from Sydney Harbor Bridge to Hilton Hotel');
|
|
162
162
|
});
|
|
163
|
+
it('should filter cached full catalog by optionId without another plugin call', async () => {
|
|
164
|
+
const { products } = await doApiPost({
|
|
165
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
166
|
+
token: userToken,
|
|
167
|
+
payload: { optionId: '2345' },
|
|
168
|
+
});
|
|
169
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
170
|
+
expect(products).toHaveLength(1);
|
|
171
|
+
expect(products[0].options).toHaveLength(1);
|
|
172
|
+
expect(products[0].options[0].optionId).toBe('2345');
|
|
173
|
+
});
|
|
174
|
+
it('should emit sanitized cache-decision telemetry without raw ids, hints, payloads, or plugin results', async () => {
|
|
175
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
176
|
+
await doApiPost({
|
|
177
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
178
|
+
token: userToken,
|
|
179
|
+
payload: { searchInput: 'Transfer from Sydney Harbor to Hilton Hotel' },
|
|
180
|
+
});
|
|
181
|
+
const decisionPayloads = emitSpy.mock.calls
|
|
182
|
+
.filter(call => call[0] === 'bookingsProductSearch:cache:decision')
|
|
183
|
+
.map(call => call[1]);
|
|
184
|
+
expect(decisionPayloads.length).toBeGreaterThan(0);
|
|
185
|
+
decisionPayloads.forEach(payload => {
|
|
186
|
+
expect(payload.userId).toBeUndefined();
|
|
187
|
+
expect(payload.hint).toBeUndefined();
|
|
188
|
+
expect(payload.cacheKey).toBeUndefined();
|
|
189
|
+
expect(payload.pluginResult).toBeUndefined();
|
|
190
|
+
expect(payload.searchInput).toBeUndefined();
|
|
191
|
+
expect(payload.optionId).toBeUndefined();
|
|
192
|
+
expect(payload.userIdHash).toBeTruthy();
|
|
193
|
+
expect(payload.hintHash).toBeTruthy();
|
|
194
|
+
expect(payload.cacheKeyHash).toBeTruthy();
|
|
195
|
+
expect(JSON.stringify(payload)).not.toContain(testUserId);
|
|
196
|
+
expect(JSON.stringify(payload)).not.toContain(testHint);
|
|
197
|
+
expect(JSON.stringify(payload)).not.toContain('Transfer from Sydney Harbor');
|
|
198
|
+
});
|
|
199
|
+
emitSpy.mockRestore();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('includes raw scope only on legacy cache-save events', async () => {
|
|
203
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
204
|
+
await doApiPost({
|
|
205
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
206
|
+
token: userToken,
|
|
207
|
+
payload: { forceRefresh: true },
|
|
208
|
+
});
|
|
209
|
+
const cacheSavePayload = emitSpy.mock.calls
|
|
210
|
+
.filter(call => call[0] === 'bookingsProductSearch:cache:save')
|
|
211
|
+
.map(call => call[1])
|
|
212
|
+
.find(payload => payload.action === 'cache_saved');
|
|
213
|
+
|
|
214
|
+
expect(cacheSavePayload).toBeTruthy();
|
|
215
|
+
expect(cacheSavePayload.userId).toBe(testUserId);
|
|
216
|
+
expect(cacheSavePayload.hint).toBe(testHint);
|
|
217
|
+
expect(cacheSavePayload.userIdHash).toBeTruthy();
|
|
218
|
+
expect(cacheSavePayload.hintHash).toBeTruthy();
|
|
219
|
+
emitSpy.mockRestore();
|
|
220
|
+
});
|
|
163
221
|
});
|
|
164
222
|
describe('doNotCallPluginForProducts flag', () => {
|
|
165
223
|
const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
|
|
@@ -260,6 +318,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
260
318
|
it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
|
|
261
319
|
travelgatePlugin.searchProducts.mockClear(); // Clear before action
|
|
262
320
|
addJob.mockClear(); // Clear addJob mock before this action that should trigger it
|
|
321
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
263
322
|
|
|
264
323
|
const { products } = await doApiPost({
|
|
265
324
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
|
|
@@ -293,6 +352,15 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
293
352
|
expect(jobData.postProcess.args.userId).toBe(testUserId);
|
|
294
353
|
expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
|
|
295
354
|
expect(jobParams).toEqual({ removeOnComplete: true });
|
|
355
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
356
|
+
'bookingsProductSearch:cache:decision',
|
|
357
|
+
expect.objectContaining({
|
|
358
|
+
action: 'stale_served',
|
|
359
|
+
reason: 'backgroundRefreshQueued',
|
|
360
|
+
jobId: 'mockJobId',
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
emitSpy.mockRestore();
|
|
296
364
|
});
|
|
297
365
|
});
|
|
298
366
|
// The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
|
|
@@ -430,13 +498,22 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
430
498
|
expect(cached.products).toEqual(initialProductsInCache);
|
|
431
499
|
expect(lastUpdated).toBeGreaterThan(1);
|
|
432
500
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
433
|
-
'bookingsProductSearch:cache:
|
|
501
|
+
'bookingsProductSearch:cache:decision',
|
|
434
502
|
expect.objectContaining({
|
|
503
|
+
action: 'empty_refresh_skipped',
|
|
435
504
|
reason: 'emptyResultPreservedExistingCache',
|
|
436
505
|
cachePreserved: true,
|
|
437
506
|
existingProductCount: initialProductsInCache.length,
|
|
438
507
|
}),
|
|
439
508
|
);
|
|
509
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
510
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
511
|
+
expect.objectContaining({
|
|
512
|
+
action: 'empty_refresh_skipped',
|
|
513
|
+
reason: 'emptyResultPreservedExistingCache',
|
|
514
|
+
cachePreserved: true,
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
440
517
|
emitSpy.mockRestore();
|
|
441
518
|
});
|
|
442
519
|
|
|
@@ -528,12 +605,21 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
528
605
|
expect(cached.products).toEqual(initialProductsInCache);
|
|
529
606
|
expect(lastUpdated).toBeGreaterThan(1);
|
|
530
607
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
531
|
-
'bookingsProductSearch:cache:
|
|
608
|
+
'bookingsProductSearch:cache:decision',
|
|
532
609
|
expect.objectContaining({
|
|
610
|
+
action: 'partial_refresh_skipped',
|
|
533
611
|
reason: 'partialResultPreservedExistingCache',
|
|
534
612
|
cachePreserved: true,
|
|
535
613
|
existingProductCount: initialProductsInCache.length,
|
|
536
|
-
pluginResult,
|
|
614
|
+
returnedProductCount: pluginResult.products.length,
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
618
|
+
'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
619
|
+
expect.objectContaining({
|
|
620
|
+
action: 'partial_refresh_skipped',
|
|
621
|
+
reason: 'partialResultPreservedExistingCache',
|
|
622
|
+
cachePreserved: true,
|
|
537
623
|
}),
|
|
538
624
|
);
|
|
539
625
|
emitSpy.mockRestore();
|
|
@@ -575,13 +661,22 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
575
661
|
value: { products: [] },
|
|
576
662
|
}));
|
|
577
663
|
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
578
|
-
'bookingsProductSearch:cache:
|
|
664
|
+
'bookingsProductSearch:cache:decision',
|
|
579
665
|
expect.objectContaining({
|
|
666
|
+
action: 'empty_refresh_skipped',
|
|
580
667
|
reason: 'emptyResultPreservedConcurrentCache',
|
|
581
668
|
cachePreserved: true,
|
|
582
669
|
existingProductCount: 1,
|
|
583
670
|
}),
|
|
584
671
|
);
|
|
672
|
+
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
673
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
674
|
+
expect.objectContaining({
|
|
675
|
+
action: 'empty_refresh_skipped',
|
|
676
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
677
|
+
cachePreserved: true,
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
585
680
|
});
|
|
586
681
|
});
|
|
587
682
|
});
|
|
@@ -777,6 +872,98 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
777
872
|
expect(addJob).not.toHaveBeenCalled();
|
|
778
873
|
});
|
|
779
874
|
|
|
875
|
+
it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
|
|
876
|
+
await clearProductSearchCache();
|
|
877
|
+
|
|
878
|
+
const refreshedProducts = [{
|
|
879
|
+
productId: 'force-product',
|
|
880
|
+
productName: 'Force Product',
|
|
881
|
+
options: [{
|
|
882
|
+
optionId: 'force-option',
|
|
883
|
+
optionName: 'Force Option',
|
|
884
|
+
}],
|
|
885
|
+
}];
|
|
886
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
887
|
+
const releasePluginFetches = [];
|
|
888
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
889
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
890
|
+
resolve();
|
|
891
|
+
return new Promise(pluginResolve => {
|
|
892
|
+
releasePluginFetches.push(() => pluginResolve({ products: refreshedProducts }));
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
const makeForceRequest = () => doApiPost({
|
|
897
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
898
|
+
token: userToken,
|
|
899
|
+
payload: { forceRefresh: true },
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
|
|
903
|
+
await pluginFetchStarted;
|
|
904
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
905
|
+
|
|
906
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
907
|
+
const results = await Promise.all(requestPromises);
|
|
908
|
+
|
|
909
|
+
results.forEach(result => {
|
|
910
|
+
expect(result.products).toEqual(refreshedProducts);
|
|
911
|
+
});
|
|
912
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
913
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
|
|
917
|
+
await clearProductSearchCache();
|
|
918
|
+
const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|
|
919
|
+
process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = '1';
|
|
920
|
+
|
|
921
|
+
const slowProducts = [{
|
|
922
|
+
productId: 'slow-product',
|
|
923
|
+
productName: 'Slow Product',
|
|
924
|
+
options: [{
|
|
925
|
+
optionId: 'slow-option',
|
|
926
|
+
optionName: 'Slow Option',
|
|
927
|
+
}],
|
|
928
|
+
}];
|
|
929
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
930
|
+
const expireIfValueSpy = jest.spyOn(lockTestPlugin.cache, 'expireIfValue');
|
|
931
|
+
const releasePluginFetches = [];
|
|
932
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
933
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
934
|
+
resolve();
|
|
935
|
+
return new Promise(pluginResolve => {
|
|
936
|
+
releasePluginFetches.push(() => pluginResolve({ products: slowProducts }));
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
const firstRequest = makeProductSearchRequest();
|
|
943
|
+
await pluginFetchStarted;
|
|
944
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
945
|
+
const secondRequest = makeProductSearchRequest();
|
|
946
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
947
|
+
|
|
948
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
949
|
+
const results = await Promise.all([firstRequest, secondRequest]);
|
|
950
|
+
|
|
951
|
+
results.forEach(result => {
|
|
952
|
+
expect(result.products).toEqual(slowProducts);
|
|
953
|
+
});
|
|
954
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
955
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
956
|
+
expect(expireIfValueSpy).toHaveBeenCalled();
|
|
957
|
+
} finally {
|
|
958
|
+
expireIfValueSpy.mockRestore();
|
|
959
|
+
if (originalLockTtl === undefined) {
|
|
960
|
+
delete process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|
|
961
|
+
} else {
|
|
962
|
+
process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = originalLockTtl;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
780
967
|
it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
|
|
781
968
|
await clearProductSearchCache();
|
|
782
969
|
|
|
@@ -170,6 +170,59 @@ describe('user: bookings controller', () => {
|
|
|
170
170
|
expect(plugins[0].createBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
171
171
|
expect(plugins[0].createBooking.mock.calls[0][0].token).toEqual(token);
|
|
172
172
|
});
|
|
173
|
+
it('should be able to confirm a booking (with hint)', async () => {
|
|
174
|
+
const payload = {
|
|
175
|
+
bookingId: '14226',
|
|
176
|
+
};
|
|
177
|
+
await doApiPost({
|
|
178
|
+
url: `/bookings/${appKey}/${userId}/testingToken/confirm`,
|
|
179
|
+
token: userToken,
|
|
180
|
+
payload,
|
|
181
|
+
});
|
|
182
|
+
expect(plugins[0].confirmBooking).toHaveBeenCalled();
|
|
183
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
184
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].token).toEqual(token);
|
|
185
|
+
});
|
|
186
|
+
it('should be able to confirm a booking (no hint)', async () => {
|
|
187
|
+
const payload = {
|
|
188
|
+
bookingId: '14226',
|
|
189
|
+
};
|
|
190
|
+
await doApiPost({
|
|
191
|
+
url: `/bookings/${appKey}/${userId}/confirm`,
|
|
192
|
+
token: userToken,
|
|
193
|
+
payload,
|
|
194
|
+
});
|
|
195
|
+
expect(plugins[0].confirmBooking).toHaveBeenCalled();
|
|
196
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
197
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].token).toEqual(token);
|
|
198
|
+
});
|
|
199
|
+
it('should emit cancelBooking operation metadata when cancelling', async () => {
|
|
200
|
+
const payload = {
|
|
201
|
+
bookingId: chance.guid(),
|
|
202
|
+
};
|
|
203
|
+
const emitSpy = jest.spyOn(plugins[0].events, 'emit');
|
|
204
|
+
try {
|
|
205
|
+
await doApiPost({
|
|
206
|
+
url: `/bookings/${appKey}/${userId}/testingToken/cancel`,
|
|
207
|
+
token: userToken,
|
|
208
|
+
payload,
|
|
209
|
+
});
|
|
210
|
+
expect(plugins[0].cancelBooking).toHaveBeenCalled();
|
|
211
|
+
expect(plugins[0].cancelBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
212
|
+
expect(plugins[0].cancelBooking.mock.calls[0][0].token).toEqual(token);
|
|
213
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
214
|
+
'bookingsCancelBooking',
|
|
215
|
+
expect.objectContaining({
|
|
216
|
+
userId,
|
|
217
|
+
hint: 'testingToken',
|
|
218
|
+
operationId: 'cancelBooking',
|
|
219
|
+
pluginName: appKey,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
emitSpy.mockRestore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
173
226
|
it('should forward inline credentials directly to create booking', async () => {
|
|
174
227
|
const payload = {
|
|
175
228
|
id: chance.guid(),
|
package/controllers/bookings.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const assert = require('assert');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const hash = require('object-hash');
|
|
3
4
|
const R = require('ramda');
|
|
4
5
|
const { UserAppKey } = require('../models/index');
|
|
@@ -34,12 +35,86 @@ const getPositiveIntegerEnv = (name, defaultValue) => {
|
|
|
34
35
|
return Number.isFinite(value) && integerValue > 0 ? integerValue : defaultValue;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
+
const getProductSearchLockTtlSeconds = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
|
|
38
39
|
const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
|
|
39
40
|
const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
|
|
40
41
|
const emptyProductSearchCacheTtlSeconds = 60;
|
|
42
|
+
const productSearchCacheTtlSeconds = 30 * 24 * 60 * 60;
|
|
43
|
+
const productSearchOperationId = 'bookingsProductSearch';
|
|
44
|
+
const productSearchCacheDecisionEvent = 'bookingsProductSearch:cache:decision';
|
|
45
|
+
const legacyProductSearchCacheEvents = {
|
|
46
|
+
cache_saved: 'bookingsProductSearch:cache:save',
|
|
47
|
+
partial_refresh_skipped: 'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
48
|
+
empty_refresh_skipped: 'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
49
|
+
empty_cache_saved: 'bookingsProductSearch:cache:emptyRefresh',
|
|
50
|
+
};
|
|
41
51
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
42
52
|
|
|
53
|
+
const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
|
|
54
|
+
|
|
55
|
+
const productCount = value => R.pathOr([], ['products'], value).length;
|
|
56
|
+
|
|
57
|
+
const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.products);
|
|
58
|
+
|
|
59
|
+
const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
|
|
60
|
+
|
|
61
|
+
const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId, searchInput, optionId, forceRefresh, startedAt }, action, extra = {}) => {
|
|
62
|
+
if (!app.events || !app.events.emit) return;
|
|
63
|
+
const payload = {
|
|
64
|
+
action,
|
|
65
|
+
operationId: productSearchOperationId,
|
|
66
|
+
requestId,
|
|
67
|
+
pluginName: app.name,
|
|
68
|
+
userIdHash: safeHash(userId),
|
|
69
|
+
hintHash: safeHash(hint),
|
|
70
|
+
cacheKeyHash: safeHash(cacheKey),
|
|
71
|
+
forceRefresh: Boolean(forceRefresh),
|
|
72
|
+
hasSearchInput: Boolean((searchInput || '').trim()),
|
|
73
|
+
hasOptionId: Boolean(optionId && optionId.length),
|
|
74
|
+
elapsedMs: Date.now() - startedAt,
|
|
75
|
+
...extra,
|
|
76
|
+
};
|
|
77
|
+
app.events.emit(productSearchCacheDecisionEvent, payload);
|
|
78
|
+
if (legacyProductSearchCacheEvents[action]) {
|
|
79
|
+
app.events.emit(legacyProductSearchCacheEvents[action], {
|
|
80
|
+
...payload,
|
|
81
|
+
userId,
|
|
82
|
+
hint,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const createLockRenewal = ({ app, key, value, ttl, onDecision }) => {
|
|
88
|
+
if (!app.cache.expireIfValue || ttl <= 0) return { stop: async () => {} };
|
|
89
|
+
|
|
90
|
+
const renewEveryMs = Math.max(250, Math.floor(ttl * 500));
|
|
91
|
+
let stopped = false;
|
|
92
|
+
let inFlightRenewal = Promise.resolve();
|
|
93
|
+
const renew = async () => {
|
|
94
|
+
if (stopped) return;
|
|
95
|
+
const renewed = await app.cache.expireIfValue({ key, value, ttl });
|
|
96
|
+
if (!renewed) {
|
|
97
|
+
stopped = true;
|
|
98
|
+
onDecision('lock_renew_lost', { reason: 'ownerTokenMismatch' });
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const interval = setInterval(() => {
|
|
102
|
+
inFlightRenewal = renew().catch(err => {
|
|
103
|
+
stopped = true;
|
|
104
|
+
onDecision('lock_renew_failed', { reason: err.message });
|
|
105
|
+
});
|
|
106
|
+
}, renewEveryMs);
|
|
107
|
+
if (interval.unref) interval.unref();
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
stop: async () => {
|
|
111
|
+
stopped = true;
|
|
112
|
+
clearInterval(interval);
|
|
113
|
+
await inFlightRenewal;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
43
118
|
const createProductSearchUnavailableError = () => {
|
|
44
119
|
const err = new Error('Product search cache refresh did not produce cached results');
|
|
45
120
|
err.status = 503;
|
|
@@ -95,6 +170,7 @@ const bookingsCancel = plugins => async (req, res, next) => {
|
|
|
95
170
|
} = req;
|
|
96
171
|
try {
|
|
97
172
|
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
173
|
+
assert(app.cancelBooking, `cancelBooking is not available for ${appKey}`);
|
|
98
174
|
const results = await app.cancelBooking({
|
|
99
175
|
axios,
|
|
100
176
|
token,
|
|
@@ -104,6 +180,46 @@ const bookingsCancel = plugins => async (req, res, next) => {
|
|
|
104
180
|
hint,
|
|
105
181
|
requestId: req.requestId,
|
|
106
182
|
});
|
|
183
|
+
app.events.emit('bookingsCancelBooking', {
|
|
184
|
+
userId,
|
|
185
|
+
hint,
|
|
186
|
+
operationId: 'cancelBooking',
|
|
187
|
+
requestId: req.requestId,
|
|
188
|
+
pluginName: app.name,
|
|
189
|
+
payload: results,
|
|
190
|
+
});
|
|
191
|
+
return res.json(results);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return next(err);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const confirmBooking = plugins => async (req, res, next) => {
|
|
198
|
+
const {
|
|
199
|
+
axios,
|
|
200
|
+
params: { appKey, userId, hint },
|
|
201
|
+
body,
|
|
202
|
+
} = req;
|
|
203
|
+
try {
|
|
204
|
+
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
205
|
+
assert(app.confirmBooking, `confirmBooking is not available for ${appKey}`);
|
|
206
|
+
const results = await app.confirmBooking({
|
|
207
|
+
axios,
|
|
208
|
+
token,
|
|
209
|
+
payload: body,
|
|
210
|
+
typeDefsAndQueries,
|
|
211
|
+
userId,
|
|
212
|
+
hint,
|
|
213
|
+
requestId: req.requestId,
|
|
214
|
+
});
|
|
215
|
+
app.events.emit('bookingsConfirmBooking', {
|
|
216
|
+
userId,
|
|
217
|
+
hint,
|
|
218
|
+
operationId: 'confirmBooking',
|
|
219
|
+
requestId: req.requestId,
|
|
220
|
+
pluginName: app.name,
|
|
221
|
+
payload: results,
|
|
222
|
+
});
|
|
107
223
|
return res.json(results);
|
|
108
224
|
} catch (err) {
|
|
109
225
|
return next(err);
|
|
@@ -183,7 +299,19 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
183
299
|
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
184
300
|
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
185
301
|
|
|
186
|
-
const cacheKey =
|
|
302
|
+
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
303
|
+
const telemetryContext = {
|
|
304
|
+
app,
|
|
305
|
+
cacheKey,
|
|
306
|
+
userId,
|
|
307
|
+
hint,
|
|
308
|
+
requestId,
|
|
309
|
+
searchInput,
|
|
310
|
+
optionId,
|
|
311
|
+
forceRefresh,
|
|
312
|
+
startedAt: Date.now(),
|
|
313
|
+
};
|
|
314
|
+
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
|
|
187
315
|
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
188
316
|
const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
|
|
189
317
|
|
|
@@ -194,11 +322,17 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
194
322
|
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
195
323
|
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
196
324
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
325
|
+
emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
|
|
326
|
+
cacheProductCount: productCount(initialActualCacheContent),
|
|
327
|
+
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
328
|
+
ttrMs: ttr * 1000,
|
|
329
|
+
reason: hasPluginExecutionLock ? 'lockActive' : undefined,
|
|
330
|
+
});
|
|
197
331
|
assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
|
|
198
332
|
|
|
199
333
|
const getCachedProductSearchResults = async () => {
|
|
200
334
|
const cacheContent = await app.cache.get({ key: cacheKey });
|
|
201
|
-
if (cacheContent
|
|
335
|
+
if (hasProductCache(cacheContent)) return cacheContent;
|
|
202
336
|
return null;
|
|
203
337
|
};
|
|
204
338
|
|
|
@@ -217,23 +351,46 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
217
351
|
return getCachedProductSearchResults();
|
|
218
352
|
};
|
|
219
353
|
|
|
220
|
-
const acquirePluginExecutionLock = async
|
|
354
|
+
const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
|
|
221
355
|
key: pluginExecutionLockKey,
|
|
222
|
-
value:
|
|
223
|
-
ttl:
|
|
356
|
+
value: lockOwnerToken,
|
|
357
|
+
ttl: getProductSearchLockTtlSeconds(),
|
|
224
358
|
});
|
|
225
359
|
|
|
226
360
|
// Helper function to call the plugin, save cache, and return results
|
|
227
|
-
const fetchFromPluginAndCache = async () => {
|
|
228
|
-
const
|
|
361
|
+
const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
|
|
362
|
+
const lockOwnerToken = crypto.randomBytes(16).toString('hex');
|
|
363
|
+
const lockStartedAt = Date.now();
|
|
364
|
+
const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
|
|
229
365
|
if (!lockAcquired) {
|
|
366
|
+
emitDecision('lock_wait', { reason, lockWaitMs: 0 });
|
|
230
367
|
const cacheContent = await waitForProductSearchCache();
|
|
231
|
-
|
|
368
|
+
const lockWaitMs = Date.now() - lockStartedAt;
|
|
369
|
+
if (cacheContent) {
|
|
370
|
+
emitDecision('cache_hit', {
|
|
371
|
+
reason: 'waitedForLeader',
|
|
372
|
+
lockWaitMs,
|
|
373
|
+
cacheProductCount: productCount(cacheContent),
|
|
374
|
+
});
|
|
375
|
+
return cacheContent;
|
|
376
|
+
}
|
|
377
|
+
emitDecision('lock_timeout', { reason, lockWaitMs });
|
|
232
378
|
throw createProductSearchUnavailableError();
|
|
233
379
|
}
|
|
234
380
|
|
|
381
|
+
const lockTtlSeconds = getProductSearchLockTtlSeconds();
|
|
382
|
+
emitDecision('lock_acquired', { reason, lockTtlSeconds });
|
|
383
|
+
const lockRenewal = createLockRenewal({
|
|
384
|
+
app,
|
|
385
|
+
key: pluginExecutionLockKey,
|
|
386
|
+
value: lockOwnerToken,
|
|
387
|
+
ttl: lockTtlSeconds,
|
|
388
|
+
onDecision: emitDecision,
|
|
389
|
+
});
|
|
390
|
+
|
|
235
391
|
let pluginResults;
|
|
236
392
|
try {
|
|
393
|
+
const pluginStartedAt = Date.now();
|
|
237
394
|
pluginResults = await func({
|
|
238
395
|
axios,
|
|
239
396
|
token,
|
|
@@ -243,51 +400,72 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
243
400
|
userId,
|
|
244
401
|
hint,
|
|
245
402
|
});
|
|
403
|
+
const pluginElapsedMs = Date.now() - pluginStartedAt;
|
|
246
404
|
|
|
247
405
|
// This function is responsible for caching if it fetched usable results.
|
|
248
406
|
// This applies to forceRefresh, initial load, or direct calls that result in a fetch.
|
|
249
407
|
// The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
|
|
250
408
|
if (hasCacheableProductResults(pluginResults)) {
|
|
251
|
-
|
|
252
|
-
await app.cache.save({ key:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
409
|
+
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
|
|
410
|
+
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
|
|
411
|
+
emitDecision('cache_saved', {
|
|
412
|
+
reason,
|
|
413
|
+
pluginElapsedMs,
|
|
414
|
+
cacheProductCount: productCount(pluginResults),
|
|
256
415
|
});
|
|
257
416
|
} else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
417
|
+
emitDecision('partial_refresh_skipped', {
|
|
418
|
+
reason: 'directPartialResultNotCached',
|
|
419
|
+
pluginElapsedMs,
|
|
420
|
+
cachePreserved: false,
|
|
421
|
+
returnedProductCount: productCount(pluginResults),
|
|
261
422
|
});
|
|
262
423
|
} else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
|
|
263
424
|
// Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
|
|
264
425
|
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
|
|
426
|
+
emitDecision('empty_cache_saved', {
|
|
427
|
+
reason,
|
|
428
|
+
pluginElapsedMs,
|
|
429
|
+
cacheProductCount: 0,
|
|
430
|
+
});
|
|
265
431
|
}
|
|
266
432
|
} finally {
|
|
267
|
-
await
|
|
433
|
+
await lockRenewal.stop();
|
|
434
|
+
if (app.cache.dropIfValue) {
|
|
435
|
+
await app.cache.dropIfValue({ key: pluginExecutionLockKey, value: lockOwnerToken });
|
|
436
|
+
} else {
|
|
437
|
+
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
438
|
+
}
|
|
268
439
|
}
|
|
269
440
|
return pluginResults || { products: [] }; // Ensure products array exists
|
|
270
441
|
};
|
|
271
442
|
|
|
272
443
|
// 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
|
|
273
444
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
274
|
-
if (initialActualCacheContent
|
|
445
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
275
446
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
447
|
+
emitDecision('cache_hit', {
|
|
448
|
+
reason: 'doNotCallPluginForProducts',
|
|
449
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
450
|
+
returnedProductCount: searchResults.length,
|
|
451
|
+
});
|
|
276
452
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
277
453
|
}
|
|
454
|
+
emitDecision('cache_miss', { reason: 'doNotCallPluginForProducts' });
|
|
278
455
|
return { products: [], ...(token.configuration || {}) };
|
|
279
456
|
}
|
|
280
457
|
|
|
281
458
|
// 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
|
|
282
459
|
// fetchFromPluginAndCache will handle caching the new results.
|
|
283
460
|
if (forceRefresh) {
|
|
284
|
-
|
|
461
|
+
emitDecision('force_refresh');
|
|
462
|
+
const funcResults = await fetchFromPluginAndCache('force_refresh');
|
|
285
463
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
286
464
|
return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
|
|
287
465
|
}
|
|
288
466
|
|
|
289
467
|
// 3. Cache exists (initialActualCacheContent) and not forceRefresh:
|
|
290
|
-
if (initialActualCacheContent
|
|
468
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
291
469
|
const cacheIsEmpty = !initialActualCacheContent.products.length;
|
|
292
470
|
const trimmedSearch = (searchInput || '').trim();
|
|
293
471
|
const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
|
|
@@ -295,8 +473,15 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
295
473
|
// If cache is empty and no search filter, skip to case 4 to fetch fresh data
|
|
296
474
|
const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
|
|
297
475
|
if (!shouldSkipEmptyCache) {
|
|
298
|
-
const returnCachedResults = () => {
|
|
476
|
+
const returnCachedResults = (action = 'cache_hit', extra = {}) => {
|
|
299
477
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
478
|
+
emitDecision(action, {
|
|
479
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
480
|
+
returnedProductCount: searchResults.length,
|
|
481
|
+
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
482
|
+
ttrMs: ttr * 1000,
|
|
483
|
+
...extra,
|
|
484
|
+
});
|
|
300
485
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
301
486
|
};
|
|
302
487
|
|
|
@@ -304,7 +489,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
304
489
|
|
|
305
490
|
// Cache is fresh or plugin execution in progress: serve from cache
|
|
306
491
|
if (!isEffectivelyStale || hasPluginExecutionLock) {
|
|
307
|
-
return returnCachedResults();
|
|
492
|
+
return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
|
|
308
493
|
}
|
|
309
494
|
|
|
310
495
|
// Cache is stale - queue at most one background refresh job and serve stale data.
|
|
@@ -314,10 +499,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
314
499
|
ttl: 60,
|
|
315
500
|
});
|
|
316
501
|
if (!jobQueueLockAcquired) {
|
|
317
|
-
return returnCachedResults();
|
|
502
|
+
return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
|
|
318
503
|
}
|
|
319
504
|
|
|
320
|
-
await addJob({
|
|
505
|
+
const jobId = await addJob({
|
|
321
506
|
type: 'plugin',
|
|
322
507
|
pluginName: appKey,
|
|
323
508
|
method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
|
|
@@ -330,13 +515,16 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
330
515
|
},
|
|
331
516
|
}, { removeOnComplete: true });
|
|
332
517
|
|
|
333
|
-
return returnCachedResults(
|
|
518
|
+
return returnCachedResults('stale_served', {
|
|
519
|
+
reason: 'backgroundRefreshQueued',
|
|
520
|
+
jobId,
|
|
521
|
+
});
|
|
334
522
|
}
|
|
335
523
|
}
|
|
336
524
|
|
|
337
525
|
// 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
|
|
338
526
|
// Fetch from plugin. fetchFromPluginAndCache will handle caching.
|
|
339
|
-
const funcResults = await fetchFromPluginAndCache();
|
|
527
|
+
const funcResults = await fetchFromPluginAndCache('cache_miss');
|
|
340
528
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
341
529
|
return {
|
|
342
530
|
...funcResults,
|
|
@@ -625,22 +813,28 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
625
813
|
return; // Or throw error
|
|
626
814
|
}
|
|
627
815
|
|
|
628
|
-
const cacheKey =
|
|
629
|
-
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
816
|
+
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
630
817
|
const markRefreshAttempted = () => app.cache.save({
|
|
631
818
|
key: `${cacheKey}:lastUpdated`,
|
|
632
819
|
value: Date.now(),
|
|
633
|
-
ttl:
|
|
634
|
-
});
|
|
635
|
-
const emitCacheEvent = (eventName, extra = {}) => app.events.emit(eventName, {
|
|
636
|
-
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
637
|
-
...extra,
|
|
820
|
+
ttl: productSearchCacheTtlSeconds,
|
|
638
821
|
});
|
|
822
|
+
const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
|
|
823
|
+
app,
|
|
824
|
+
cacheKey,
|
|
825
|
+
userId,
|
|
826
|
+
hint,
|
|
827
|
+
requestId,
|
|
828
|
+
searchInput: '',
|
|
829
|
+
optionId: '',
|
|
830
|
+
forceRefresh: false,
|
|
831
|
+
startedAt: Date.now(),
|
|
832
|
+
}, action, extra);
|
|
639
833
|
|
|
640
834
|
if (hasCacheableProductResults(pluginResult)) {
|
|
641
835
|
await markRefreshAttempted();
|
|
642
|
-
await app.cache.save({ key: cacheKey, value: pluginResult, ttl:
|
|
643
|
-
emitCacheEvent('
|
|
836
|
+
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
|
|
837
|
+
emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
|
|
644
838
|
} else {
|
|
645
839
|
const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
|
|
646
840
|
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
@@ -651,9 +845,9 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
651
845
|
cachePreserved: true,
|
|
652
846
|
existingProductCount: existingCacheContent.products.length,
|
|
653
847
|
};
|
|
654
|
-
if (isPartialRefresh) eventExtra.
|
|
848
|
+
if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
|
|
655
849
|
emitCacheEvent(
|
|
656
|
-
isPartialRefresh ? '
|
|
850
|
+
isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
|
|
657
851
|
eventExtra,
|
|
658
852
|
);
|
|
659
853
|
return;
|
|
@@ -661,10 +855,10 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
661
855
|
|
|
662
856
|
if (isPartialRefresh) {
|
|
663
857
|
await markRefreshAttempted();
|
|
664
|
-
emitCacheEvent('
|
|
858
|
+
emitCacheEvent('partial_refresh_skipped', {
|
|
665
859
|
reason: 'partialResultNotCached',
|
|
666
860
|
cachePreserved: false,
|
|
667
|
-
pluginResult,
|
|
861
|
+
returnedProductCount: productCount(pluginResult),
|
|
668
862
|
});
|
|
669
863
|
return;
|
|
670
864
|
}
|
|
@@ -673,7 +867,7 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
673
867
|
const latestCacheContent = await app.cache.get({ key: cacheKey });
|
|
674
868
|
if (hasNonEmptyProductCache(latestCacheContent)) {
|
|
675
869
|
await markRefreshAttempted();
|
|
676
|
-
emitCacheEvent('
|
|
870
|
+
emitCacheEvent('empty_refresh_skipped', {
|
|
677
871
|
reason: 'emptyResultPreservedConcurrentCache',
|
|
678
872
|
cachePreserved: true,
|
|
679
873
|
existingProductCount: latestCacheContent.products.length,
|
|
@@ -683,8 +877,8 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
683
877
|
|
|
684
878
|
// Empty complete refreshes are authoritative only when there is no existing non-empty cache.
|
|
685
879
|
await markRefreshAttempted();
|
|
686
|
-
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl:
|
|
687
|
-
emitCacheEvent('
|
|
880
|
+
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
|
|
881
|
+
emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
|
|
688
882
|
}
|
|
689
883
|
};
|
|
690
884
|
|
|
@@ -693,6 +887,7 @@ const controllerFactory = plugins => {
|
|
|
693
887
|
const controllerFunctions = {
|
|
694
888
|
bookingsSearch: bookingsSearch(plugins),
|
|
695
889
|
bookingsCancel: bookingsCancel(plugins),
|
|
890
|
+
confirmBooking: confirmBooking(plugins),
|
|
696
891
|
$bookingsProductSearch: $bookingsProductSearch(plugins),
|
|
697
892
|
bookingsProductSearch: bookingsProductSearch(plugins),
|
|
698
893
|
getProductPackages: getProductPackages(plugins),
|
package/index.js
CHANGED
|
@@ -91,6 +91,8 @@ module.exports = async ({
|
|
|
91
91
|
getOrExec: args => cache.getOrExec({ ...args, pluginName }),
|
|
92
92
|
save: args => cache.save({ ...args, pluginName }),
|
|
93
93
|
saveIfNotExists: args => cache.saveIfNotExists({ ...args, pluginName }),
|
|
94
|
+
expireIfValue: args => cache.expireIfValue({ ...args, pluginName }),
|
|
95
|
+
dropIfValue: args => cache.dropIfValue({ ...args, pluginName }),
|
|
94
96
|
scan: args => cache.scan({ ...args, pluginName }),
|
|
95
97
|
},
|
|
96
98
|
axios: pluginAxios,
|
package/package.json
CHANGED
package/test/plugin.js
CHANGED
|
@@ -75,6 +75,21 @@ class Plugin {
|
|
|
75
75
|
});
|
|
76
76
|
this.searchQuote = jestPlugin.fn(() => ({ quote: [{ id: chance.guid() }] }));
|
|
77
77
|
this.createBooking = jestPlugin.fn(() => {});
|
|
78
|
+
this.cancelBooking = jestPlugin.fn(() => ({
|
|
79
|
+
cancelServicesReply: {},
|
|
80
|
+
cancellation: {
|
|
81
|
+
id: chance.guid(),
|
|
82
|
+
status: 'Cancelled',
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
this.confirmBooking = jestPlugin.fn(() => ({
|
|
86
|
+
confirmBookingReply: {},
|
|
87
|
+
booking: {
|
|
88
|
+
id: chance.guid(),
|
|
89
|
+
status: 'OK',
|
|
90
|
+
serviceLines: [],
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
78
93
|
this.getAffiliateAgents = jestPlugin.fn(() => ({ agents: [] }));
|
|
79
94
|
this.searchItineraries = jestPlugin.fn(() => ({ bookings: [] }));
|
|
80
95
|
this.searchProductsForItinerary = jestPlugin.fn(() => ({ products: [] }));
|
|
@@ -535,6 +550,32 @@ class Plugin {
|
|
|
535
550
|
*/
|
|
536
551
|
createBooking() {}
|
|
537
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Cancel a booking
|
|
555
|
+
* @async
|
|
556
|
+
* @param {Object} args - Cancel booking arguments.
|
|
557
|
+
* @param {Object} args.token - A token definition, it's content varies between integrations.
|
|
558
|
+
* @param {Object} args.payload - Booking identifier payload.
|
|
559
|
+
* @returns {object} retVal - the return object.
|
|
560
|
+
*/
|
|
561
|
+
cancelBooking() {}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Confirm a quote as a booking
|
|
565
|
+
* @async
|
|
566
|
+
* @param {Object} args - Confirm booking arguments.
|
|
567
|
+
* @param {Object} args.token - A token definition, it's content varies between integrations.
|
|
568
|
+
* @param {Object} args.payload - Booking identifier payload.
|
|
569
|
+
* @param {string} [args.payload.bookingId] - Booking id to confirm.
|
|
570
|
+
* @param {string} [args.payload.id] - Booking id to confirm.
|
|
571
|
+
* @param {string} [args.payload.ref] - Booking reference to confirm.
|
|
572
|
+
* @param {string} [args.payload.reference] - Booking reference to confirm.
|
|
573
|
+
* @returns {object} retVal - the return object.
|
|
574
|
+
* @returns {Object} retVal.confirmBookingReply - Raw integration reply.
|
|
575
|
+
* @returns {Booking} retVal.booking - A normalized Booking object.
|
|
576
|
+
*/
|
|
577
|
+
confirmBooking() {}
|
|
578
|
+
|
|
538
579
|
/**
|
|
539
580
|
* @typedef {Object} Passenger
|
|
540
581
|
* @property {string} firstName - Passenger first name
|
|
@@ -39,6 +39,8 @@ We are using a set of standardized methods to maintain compatibility; the follow
|
|
|
39
39
|
* [searchAvailability]{@link Plugin#searchAvailability}
|
|
40
40
|
* [searchQuote]{@link Plugin#searchQuote}
|
|
41
41
|
* [createBooking]{@link Plugin#createBooking}
|
|
42
|
+
* [cancelBooking]{@link Plugin#cancelBooking}
|
|
43
|
+
* [confirmBooking]{@link Plugin#confirmBooking}
|
|
42
44
|
* [searchProductsForItinerary]{@link Plugin#searchProductsForItinerary}
|
|
43
45
|
* [searchAvailabilityForItinerary]{@link Plugin#searchAvailabilityForItinerary}
|
|
44
46
|
* [addServiceToItinerary]{@link Plugin#addServiceToItinerary}
|