ti2 1.0.116 → 1.0.118
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 +172 -5
- package/controllers/__tests__/bookings.js +53 -0
- package/controllers/bookings.js +234 -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/.windsurfrules +0 -9
- package/CLAUDE.md +0 -166
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,44 @@ 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
|
+
});
|
|
163
201
|
});
|
|
164
202
|
describe('doNotCallPluginForProducts flag', () => {
|
|
165
203
|
const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
|
|
@@ -260,6 +298,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
260
298
|
it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
|
|
261
299
|
travelgatePlugin.searchProducts.mockClear(); // Clear before action
|
|
262
300
|
addJob.mockClear(); // Clear addJob mock before this action that should trigger it
|
|
301
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
263
302
|
|
|
264
303
|
const { products } = await doApiPost({
|
|
265
304
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
|
|
@@ -293,6 +332,15 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
293
332
|
expect(jobData.postProcess.args.userId).toBe(testUserId);
|
|
294
333
|
expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
|
|
295
334
|
expect(jobParams).toEqual({ removeOnComplete: true });
|
|
335
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
336
|
+
'bookingsProductSearch:cache:decision',
|
|
337
|
+
expect.objectContaining({
|
|
338
|
+
action: 'stale_served',
|
|
339
|
+
reason: 'backgroundRefreshQueued',
|
|
340
|
+
jobId: 'mockJobId',
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
emitSpy.mockRestore();
|
|
296
344
|
});
|
|
297
345
|
});
|
|
298
346
|
// The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
|
|
@@ -430,13 +478,22 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
430
478
|
expect(cached.products).toEqual(initialProductsInCache);
|
|
431
479
|
expect(lastUpdated).toBeGreaterThan(1);
|
|
432
480
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
433
|
-
'bookingsProductSearch:cache:
|
|
481
|
+
'bookingsProductSearch:cache:decision',
|
|
434
482
|
expect.objectContaining({
|
|
483
|
+
action: 'empty_refresh_skipped',
|
|
435
484
|
reason: 'emptyResultPreservedExistingCache',
|
|
436
485
|
cachePreserved: true,
|
|
437
486
|
existingProductCount: initialProductsInCache.length,
|
|
438
487
|
}),
|
|
439
488
|
);
|
|
489
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
490
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
491
|
+
expect.objectContaining({
|
|
492
|
+
action: 'empty_refresh_skipped',
|
|
493
|
+
reason: 'emptyResultPreservedExistingCache',
|
|
494
|
+
cachePreserved: true,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
440
497
|
emitSpy.mockRestore();
|
|
441
498
|
});
|
|
442
499
|
|
|
@@ -528,12 +585,21 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
528
585
|
expect(cached.products).toEqual(initialProductsInCache);
|
|
529
586
|
expect(lastUpdated).toBeGreaterThan(1);
|
|
530
587
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
531
|
-
'bookingsProductSearch:cache:
|
|
588
|
+
'bookingsProductSearch:cache:decision',
|
|
532
589
|
expect.objectContaining({
|
|
590
|
+
action: 'partial_refresh_skipped',
|
|
533
591
|
reason: 'partialResultPreservedExistingCache',
|
|
534
592
|
cachePreserved: true,
|
|
535
593
|
existingProductCount: initialProductsInCache.length,
|
|
536
|
-
pluginResult,
|
|
594
|
+
returnedProductCount: pluginResult.products.length,
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
598
|
+
'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
599
|
+
expect.objectContaining({
|
|
600
|
+
action: 'partial_refresh_skipped',
|
|
601
|
+
reason: 'partialResultPreservedExistingCache',
|
|
602
|
+
cachePreserved: true,
|
|
537
603
|
}),
|
|
538
604
|
);
|
|
539
605
|
emitSpy.mockRestore();
|
|
@@ -575,13 +641,22 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
575
641
|
value: { products: [] },
|
|
576
642
|
}));
|
|
577
643
|
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
578
|
-
'bookingsProductSearch:cache:
|
|
644
|
+
'bookingsProductSearch:cache:decision',
|
|
579
645
|
expect.objectContaining({
|
|
646
|
+
action: 'empty_refresh_skipped',
|
|
580
647
|
reason: 'emptyResultPreservedConcurrentCache',
|
|
581
648
|
cachePreserved: true,
|
|
582
649
|
existingProductCount: 1,
|
|
583
650
|
}),
|
|
584
651
|
);
|
|
652
|
+
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
653
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
654
|
+
expect.objectContaining({
|
|
655
|
+
action: 'empty_refresh_skipped',
|
|
656
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
657
|
+
cachePreserved: true,
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
585
660
|
});
|
|
586
661
|
});
|
|
587
662
|
});
|
|
@@ -777,6 +852,98 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
777
852
|
expect(addJob).not.toHaveBeenCalled();
|
|
778
853
|
});
|
|
779
854
|
|
|
855
|
+
it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
|
|
856
|
+
await clearProductSearchCache();
|
|
857
|
+
|
|
858
|
+
const refreshedProducts = [{
|
|
859
|
+
productId: 'force-product',
|
|
860
|
+
productName: 'Force Product',
|
|
861
|
+
options: [{
|
|
862
|
+
optionId: 'force-option',
|
|
863
|
+
optionName: 'Force Option',
|
|
864
|
+
}],
|
|
865
|
+
}];
|
|
866
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
867
|
+
const releasePluginFetches = [];
|
|
868
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
869
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
870
|
+
resolve();
|
|
871
|
+
return new Promise(pluginResolve => {
|
|
872
|
+
releasePluginFetches.push(() => pluginResolve({ products: refreshedProducts }));
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
const makeForceRequest = () => doApiPost({
|
|
877
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
878
|
+
token: userToken,
|
|
879
|
+
payload: { forceRefresh: true },
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
|
|
883
|
+
await pluginFetchStarted;
|
|
884
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
885
|
+
|
|
886
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
887
|
+
const results = await Promise.all(requestPromises);
|
|
888
|
+
|
|
889
|
+
results.forEach(result => {
|
|
890
|
+
expect(result.products).toEqual(refreshedProducts);
|
|
891
|
+
});
|
|
892
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
893
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
|
|
897
|
+
await clearProductSearchCache();
|
|
898
|
+
const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|
|
899
|
+
process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = '1';
|
|
900
|
+
|
|
901
|
+
const slowProducts = [{
|
|
902
|
+
productId: 'slow-product',
|
|
903
|
+
productName: 'Slow Product',
|
|
904
|
+
options: [{
|
|
905
|
+
optionId: 'slow-option',
|
|
906
|
+
optionName: 'Slow Option',
|
|
907
|
+
}],
|
|
908
|
+
}];
|
|
909
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
910
|
+
const expireIfValueSpy = jest.spyOn(lockTestPlugin.cache, 'expireIfValue');
|
|
911
|
+
const releasePluginFetches = [];
|
|
912
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
913
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
914
|
+
resolve();
|
|
915
|
+
return new Promise(pluginResolve => {
|
|
916
|
+
releasePluginFetches.push(() => pluginResolve({ products: slowProducts }));
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const firstRequest = makeProductSearchRequest();
|
|
923
|
+
await pluginFetchStarted;
|
|
924
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
925
|
+
const secondRequest = makeProductSearchRequest();
|
|
926
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
927
|
+
|
|
928
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
929
|
+
const results = await Promise.all([firstRequest, secondRequest]);
|
|
930
|
+
|
|
931
|
+
results.forEach(result => {
|
|
932
|
+
expect(result.products).toEqual(slowProducts);
|
|
933
|
+
});
|
|
934
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
935
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
936
|
+
expect(expireIfValueSpy).toHaveBeenCalled();
|
|
937
|
+
} finally {
|
|
938
|
+
expireIfValueSpy.mockRestore();
|
|
939
|
+
if (originalLockTtl === undefined) {
|
|
940
|
+
delete process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|
|
941
|
+
} else {
|
|
942
|
+
process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = originalLockTtl;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
780
947
|
it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
|
|
781
948
|
await clearProductSearchCache();
|
|
782
949
|
|
|
@@ -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,82 @@ 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], payload);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createLockRenewal = ({ app, key, value, ttl, onDecision }) => {
|
|
84
|
+
if (!app.cache.expireIfValue || ttl <= 0) return { stop: async () => {} };
|
|
85
|
+
|
|
86
|
+
const renewEveryMs = Math.max(250, Math.floor(ttl * 500));
|
|
87
|
+
let stopped = false;
|
|
88
|
+
let inFlightRenewal = Promise.resolve();
|
|
89
|
+
const renew = async () => {
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
const renewed = await app.cache.expireIfValue({ key, value, ttl });
|
|
92
|
+
if (!renewed) {
|
|
93
|
+
stopped = true;
|
|
94
|
+
onDecision('lock_renew_lost', { reason: 'ownerTokenMismatch' });
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const interval = setInterval(() => {
|
|
98
|
+
inFlightRenewal = renew().catch(err => {
|
|
99
|
+
stopped = true;
|
|
100
|
+
onDecision('lock_renew_failed', { reason: err.message });
|
|
101
|
+
});
|
|
102
|
+
}, renewEveryMs);
|
|
103
|
+
if (interval.unref) interval.unref();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
stop: async () => {
|
|
107
|
+
stopped = true;
|
|
108
|
+
clearInterval(interval);
|
|
109
|
+
await inFlightRenewal;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
43
114
|
const createProductSearchUnavailableError = () => {
|
|
44
115
|
const err = new Error('Product search cache refresh did not produce cached results');
|
|
45
116
|
err.status = 503;
|
|
@@ -95,6 +166,7 @@ const bookingsCancel = plugins => async (req, res, next) => {
|
|
|
95
166
|
} = req;
|
|
96
167
|
try {
|
|
97
168
|
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
169
|
+
assert(app.cancelBooking, `cancelBooking is not available for ${appKey}`);
|
|
98
170
|
const results = await app.cancelBooking({
|
|
99
171
|
axios,
|
|
100
172
|
token,
|
|
@@ -104,6 +176,46 @@ const bookingsCancel = plugins => async (req, res, next) => {
|
|
|
104
176
|
hint,
|
|
105
177
|
requestId: req.requestId,
|
|
106
178
|
});
|
|
179
|
+
app.events.emit('bookingsCancelBooking', {
|
|
180
|
+
userId,
|
|
181
|
+
hint,
|
|
182
|
+
operationId: 'cancelBooking',
|
|
183
|
+
requestId: req.requestId,
|
|
184
|
+
pluginName: app.name,
|
|
185
|
+
payload: results,
|
|
186
|
+
});
|
|
187
|
+
return res.json(results);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return next(err);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const confirmBooking = plugins => async (req, res, next) => {
|
|
194
|
+
const {
|
|
195
|
+
axios,
|
|
196
|
+
params: { appKey, userId, hint },
|
|
197
|
+
body,
|
|
198
|
+
} = req;
|
|
199
|
+
try {
|
|
200
|
+
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
201
|
+
assert(app.confirmBooking, `confirmBooking is not available for ${appKey}`);
|
|
202
|
+
const results = await app.confirmBooking({
|
|
203
|
+
axios,
|
|
204
|
+
token,
|
|
205
|
+
payload: body,
|
|
206
|
+
typeDefsAndQueries,
|
|
207
|
+
userId,
|
|
208
|
+
hint,
|
|
209
|
+
requestId: req.requestId,
|
|
210
|
+
});
|
|
211
|
+
app.events.emit('bookingsConfirmBooking', {
|
|
212
|
+
userId,
|
|
213
|
+
hint,
|
|
214
|
+
operationId: 'confirmBooking',
|
|
215
|
+
requestId: req.requestId,
|
|
216
|
+
pluginName: app.name,
|
|
217
|
+
payload: results,
|
|
218
|
+
});
|
|
107
219
|
return res.json(results);
|
|
108
220
|
} catch (err) {
|
|
109
221
|
return next(err);
|
|
@@ -183,7 +295,19 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
183
295
|
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
184
296
|
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
185
297
|
|
|
186
|
-
const cacheKey =
|
|
298
|
+
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
299
|
+
const telemetryContext = {
|
|
300
|
+
app,
|
|
301
|
+
cacheKey,
|
|
302
|
+
userId,
|
|
303
|
+
hint,
|
|
304
|
+
requestId,
|
|
305
|
+
searchInput,
|
|
306
|
+
optionId,
|
|
307
|
+
forceRefresh,
|
|
308
|
+
startedAt: Date.now(),
|
|
309
|
+
};
|
|
310
|
+
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
|
|
187
311
|
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
188
312
|
const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
|
|
189
313
|
|
|
@@ -194,11 +318,17 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
194
318
|
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
195
319
|
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
196
320
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
321
|
+
emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
|
|
322
|
+
cacheProductCount: productCount(initialActualCacheContent),
|
|
323
|
+
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
324
|
+
ttrMs: ttr * 1000,
|
|
325
|
+
reason: hasPluginExecutionLock ? 'lockActive' : undefined,
|
|
326
|
+
});
|
|
197
327
|
assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
|
|
198
328
|
|
|
199
329
|
const getCachedProductSearchResults = async () => {
|
|
200
330
|
const cacheContent = await app.cache.get({ key: cacheKey });
|
|
201
|
-
if (cacheContent
|
|
331
|
+
if (hasProductCache(cacheContent)) return cacheContent;
|
|
202
332
|
return null;
|
|
203
333
|
};
|
|
204
334
|
|
|
@@ -217,23 +347,46 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
217
347
|
return getCachedProductSearchResults();
|
|
218
348
|
};
|
|
219
349
|
|
|
220
|
-
const acquirePluginExecutionLock = async
|
|
350
|
+
const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
|
|
221
351
|
key: pluginExecutionLockKey,
|
|
222
|
-
value:
|
|
223
|
-
ttl:
|
|
352
|
+
value: lockOwnerToken,
|
|
353
|
+
ttl: getProductSearchLockTtlSeconds(),
|
|
224
354
|
});
|
|
225
355
|
|
|
226
356
|
// Helper function to call the plugin, save cache, and return results
|
|
227
|
-
const fetchFromPluginAndCache = async () => {
|
|
228
|
-
const
|
|
357
|
+
const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
|
|
358
|
+
const lockOwnerToken = crypto.randomBytes(16).toString('hex');
|
|
359
|
+
const lockStartedAt = Date.now();
|
|
360
|
+
const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
|
|
229
361
|
if (!lockAcquired) {
|
|
362
|
+
emitDecision('lock_wait', { reason, lockWaitMs: 0 });
|
|
230
363
|
const cacheContent = await waitForProductSearchCache();
|
|
231
|
-
|
|
364
|
+
const lockWaitMs = Date.now() - lockStartedAt;
|
|
365
|
+
if (cacheContent) {
|
|
366
|
+
emitDecision('cache_hit', {
|
|
367
|
+
reason: 'waitedForLeader',
|
|
368
|
+
lockWaitMs,
|
|
369
|
+
cacheProductCount: productCount(cacheContent),
|
|
370
|
+
});
|
|
371
|
+
return cacheContent;
|
|
372
|
+
}
|
|
373
|
+
emitDecision('lock_timeout', { reason, lockWaitMs });
|
|
232
374
|
throw createProductSearchUnavailableError();
|
|
233
375
|
}
|
|
234
376
|
|
|
377
|
+
const lockTtlSeconds = getProductSearchLockTtlSeconds();
|
|
378
|
+
emitDecision('lock_acquired', { reason, lockTtlSeconds });
|
|
379
|
+
const lockRenewal = createLockRenewal({
|
|
380
|
+
app,
|
|
381
|
+
key: pluginExecutionLockKey,
|
|
382
|
+
value: lockOwnerToken,
|
|
383
|
+
ttl: lockTtlSeconds,
|
|
384
|
+
onDecision: emitDecision,
|
|
385
|
+
});
|
|
386
|
+
|
|
235
387
|
let pluginResults;
|
|
236
388
|
try {
|
|
389
|
+
const pluginStartedAt = Date.now();
|
|
237
390
|
pluginResults = await func({
|
|
238
391
|
axios,
|
|
239
392
|
token,
|
|
@@ -243,51 +396,72 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
243
396
|
userId,
|
|
244
397
|
hint,
|
|
245
398
|
});
|
|
399
|
+
const pluginElapsedMs = Date.now() - pluginStartedAt;
|
|
246
400
|
|
|
247
401
|
// This function is responsible for caching if it fetched usable results.
|
|
248
402
|
// This applies to forceRefresh, initial load, or direct calls that result in a fetch.
|
|
249
403
|
// The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
|
|
250
404
|
if (hasCacheableProductResults(pluginResults)) {
|
|
251
|
-
|
|
252
|
-
await app.cache.save({ key:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
405
|
+
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
|
|
406
|
+
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
|
|
407
|
+
emitDecision('cache_saved', {
|
|
408
|
+
reason,
|
|
409
|
+
pluginElapsedMs,
|
|
410
|
+
cacheProductCount: productCount(pluginResults),
|
|
256
411
|
});
|
|
257
412
|
} else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
413
|
+
emitDecision('partial_refresh_skipped', {
|
|
414
|
+
reason: 'directPartialResultNotCached',
|
|
415
|
+
pluginElapsedMs,
|
|
416
|
+
cachePreserved: false,
|
|
417
|
+
returnedProductCount: productCount(pluginResults),
|
|
261
418
|
});
|
|
262
419
|
} else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
|
|
263
420
|
// Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
|
|
264
421
|
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
|
|
422
|
+
emitDecision('empty_cache_saved', {
|
|
423
|
+
reason,
|
|
424
|
+
pluginElapsedMs,
|
|
425
|
+
cacheProductCount: 0,
|
|
426
|
+
});
|
|
265
427
|
}
|
|
266
428
|
} finally {
|
|
267
|
-
await
|
|
429
|
+
await lockRenewal.stop();
|
|
430
|
+
if (app.cache.dropIfValue) {
|
|
431
|
+
await app.cache.dropIfValue({ key: pluginExecutionLockKey, value: lockOwnerToken });
|
|
432
|
+
} else {
|
|
433
|
+
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
434
|
+
}
|
|
268
435
|
}
|
|
269
436
|
return pluginResults || { products: [] }; // Ensure products array exists
|
|
270
437
|
};
|
|
271
438
|
|
|
272
439
|
// 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
|
|
273
440
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
274
|
-
if (initialActualCacheContent
|
|
441
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
275
442
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
443
|
+
emitDecision('cache_hit', {
|
|
444
|
+
reason: 'doNotCallPluginForProducts',
|
|
445
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
446
|
+
returnedProductCount: searchResults.length,
|
|
447
|
+
});
|
|
276
448
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
277
449
|
}
|
|
450
|
+
emitDecision('cache_miss', { reason: 'doNotCallPluginForProducts' });
|
|
278
451
|
return { products: [], ...(token.configuration || {}) };
|
|
279
452
|
}
|
|
280
453
|
|
|
281
454
|
// 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
|
|
282
455
|
// fetchFromPluginAndCache will handle caching the new results.
|
|
283
456
|
if (forceRefresh) {
|
|
284
|
-
|
|
457
|
+
emitDecision('force_refresh');
|
|
458
|
+
const funcResults = await fetchFromPluginAndCache('force_refresh');
|
|
285
459
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
286
460
|
return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
|
|
287
461
|
}
|
|
288
462
|
|
|
289
463
|
// 3. Cache exists (initialActualCacheContent) and not forceRefresh:
|
|
290
|
-
if (initialActualCacheContent
|
|
464
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
291
465
|
const cacheIsEmpty = !initialActualCacheContent.products.length;
|
|
292
466
|
const trimmedSearch = (searchInput || '').trim();
|
|
293
467
|
const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
|
|
@@ -295,8 +469,15 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
295
469
|
// If cache is empty and no search filter, skip to case 4 to fetch fresh data
|
|
296
470
|
const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
|
|
297
471
|
if (!shouldSkipEmptyCache) {
|
|
298
|
-
const returnCachedResults = () => {
|
|
472
|
+
const returnCachedResults = (action = 'cache_hit', extra = {}) => {
|
|
299
473
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
474
|
+
emitDecision(action, {
|
|
475
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
476
|
+
returnedProductCount: searchResults.length,
|
|
477
|
+
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
478
|
+
ttrMs: ttr * 1000,
|
|
479
|
+
...extra,
|
|
480
|
+
});
|
|
300
481
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
301
482
|
};
|
|
302
483
|
|
|
@@ -304,7 +485,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
304
485
|
|
|
305
486
|
// Cache is fresh or plugin execution in progress: serve from cache
|
|
306
487
|
if (!isEffectivelyStale || hasPluginExecutionLock) {
|
|
307
|
-
return returnCachedResults();
|
|
488
|
+
return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
|
|
308
489
|
}
|
|
309
490
|
|
|
310
491
|
// Cache is stale - queue at most one background refresh job and serve stale data.
|
|
@@ -314,10 +495,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
314
495
|
ttl: 60,
|
|
315
496
|
});
|
|
316
497
|
if (!jobQueueLockAcquired) {
|
|
317
|
-
return returnCachedResults();
|
|
498
|
+
return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
|
|
318
499
|
}
|
|
319
500
|
|
|
320
|
-
await addJob({
|
|
501
|
+
const jobId = await addJob({
|
|
321
502
|
type: 'plugin',
|
|
322
503
|
pluginName: appKey,
|
|
323
504
|
method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
|
|
@@ -330,13 +511,16 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
330
511
|
},
|
|
331
512
|
}, { removeOnComplete: true });
|
|
332
513
|
|
|
333
|
-
return returnCachedResults(
|
|
514
|
+
return returnCachedResults('stale_served', {
|
|
515
|
+
reason: 'backgroundRefreshQueued',
|
|
516
|
+
jobId,
|
|
517
|
+
});
|
|
334
518
|
}
|
|
335
519
|
}
|
|
336
520
|
|
|
337
521
|
// 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
|
|
338
522
|
// Fetch from plugin. fetchFromPluginAndCache will handle caching.
|
|
339
|
-
const funcResults = await fetchFromPluginAndCache();
|
|
523
|
+
const funcResults = await fetchFromPluginAndCache('cache_miss');
|
|
340
524
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
341
525
|
return {
|
|
342
526
|
...funcResults,
|
|
@@ -625,22 +809,28 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
625
809
|
return; // Or throw error
|
|
626
810
|
}
|
|
627
811
|
|
|
628
|
-
const cacheKey =
|
|
629
|
-
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
812
|
+
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
630
813
|
const markRefreshAttempted = () => app.cache.save({
|
|
631
814
|
key: `${cacheKey}:lastUpdated`,
|
|
632
815
|
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,
|
|
816
|
+
ttl: productSearchCacheTtlSeconds,
|
|
638
817
|
});
|
|
818
|
+
const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
|
|
819
|
+
app,
|
|
820
|
+
cacheKey,
|
|
821
|
+
userId,
|
|
822
|
+
hint,
|
|
823
|
+
requestId,
|
|
824
|
+
searchInput: '',
|
|
825
|
+
optionId: '',
|
|
826
|
+
forceRefresh: false,
|
|
827
|
+
startedAt: Date.now(),
|
|
828
|
+
}, action, extra);
|
|
639
829
|
|
|
640
830
|
if (hasCacheableProductResults(pluginResult)) {
|
|
641
831
|
await markRefreshAttempted();
|
|
642
|
-
await app.cache.save({ key: cacheKey, value: pluginResult, ttl:
|
|
643
|
-
emitCacheEvent('
|
|
832
|
+
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
|
|
833
|
+
emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
|
|
644
834
|
} else {
|
|
645
835
|
const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
|
|
646
836
|
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
@@ -651,9 +841,9 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
651
841
|
cachePreserved: true,
|
|
652
842
|
existingProductCount: existingCacheContent.products.length,
|
|
653
843
|
};
|
|
654
|
-
if (isPartialRefresh) eventExtra.
|
|
844
|
+
if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
|
|
655
845
|
emitCacheEvent(
|
|
656
|
-
isPartialRefresh ? '
|
|
846
|
+
isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
|
|
657
847
|
eventExtra,
|
|
658
848
|
);
|
|
659
849
|
return;
|
|
@@ -661,10 +851,10 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
661
851
|
|
|
662
852
|
if (isPartialRefresh) {
|
|
663
853
|
await markRefreshAttempted();
|
|
664
|
-
emitCacheEvent('
|
|
854
|
+
emitCacheEvent('partial_refresh_skipped', {
|
|
665
855
|
reason: 'partialResultNotCached',
|
|
666
856
|
cachePreserved: false,
|
|
667
|
-
pluginResult,
|
|
857
|
+
returnedProductCount: productCount(pluginResult),
|
|
668
858
|
});
|
|
669
859
|
return;
|
|
670
860
|
}
|
|
@@ -673,7 +863,7 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
673
863
|
const latestCacheContent = await app.cache.get({ key: cacheKey });
|
|
674
864
|
if (hasNonEmptyProductCache(latestCacheContent)) {
|
|
675
865
|
await markRefreshAttempted();
|
|
676
|
-
emitCacheEvent('
|
|
866
|
+
emitCacheEvent('empty_refresh_skipped', {
|
|
677
867
|
reason: 'emptyResultPreservedConcurrentCache',
|
|
678
868
|
cachePreserved: true,
|
|
679
869
|
existingProductCount: latestCacheContent.products.length,
|
|
@@ -683,8 +873,8 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
683
873
|
|
|
684
874
|
// Empty complete refreshes are authoritative only when there is no existing non-empty cache.
|
|
685
875
|
await markRefreshAttempted();
|
|
686
|
-
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl:
|
|
687
|
-
emitCacheEvent('
|
|
876
|
+
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
|
|
877
|
+
emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
|
|
688
878
|
}
|
|
689
879
|
};
|
|
690
880
|
|
|
@@ -693,6 +883,7 @@ const controllerFactory = plugins => {
|
|
|
693
883
|
const controllerFunctions = {
|
|
694
884
|
bookingsSearch: bookingsSearch(plugins),
|
|
695
885
|
bookingsCancel: bookingsCancel(plugins),
|
|
886
|
+
confirmBooking: confirmBooking(plugins),
|
|
696
887
|
$bookingsProductSearch: $bookingsProductSearch(plugins),
|
|
697
888
|
bookingsProductSearch: bookingsProductSearch(plugins),
|
|
698
889
|
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}
|
package/.windsurfrules
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
This is a Node.js project using Sequelize for database operations and Express.js for the web server.
|
|
2
|
-
The API is defined in api.yml using OpenAPI 3.0.2
|
|
3
|
-
The database is defined in models/index.js
|
|
4
|
-
The models are defined in models/
|
|
5
|
-
to run tests you have to run them on the ti2 named docker container, and consider this codebase is part of node_modules/ti2 path for example :
|
|
6
|
-
`` $ docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"``
|
|
7
|
-
When writing / editing tests, make use of test/utils.js and review their implementation to understand how to mock the environment.
|
|
8
|
-
Do not make any changes until you 95% confident of the changes you want to make, ask follow up questions until you have that confidence.
|
|
9
|
-
when sending jobs to the background worker using the queue, asssume a worker is always runing to execute it but is on a different instance thread, so to review / wait for jobs we have to use the wokrer/queue.js listJobs fn to check for it
|
package/CLAUDE.md
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Project Overview
|
|
6
|
-
|
|
7
|
-
Ti2 (Tourism Information Interchange) is an open-source integration framework for the tourism industry. It provides standardized functions for bookings, content, and rates management through a plugin-based architecture.
|
|
8
|
-
|
|
9
|
-
## Critical Development Rules
|
|
10
|
-
|
|
11
|
-
1. **Docker Container Execution**: ALL commands must be run inside the `ti2` Docker container
|
|
12
|
-
- The codebase is located at `/ti2` path inside the container
|
|
13
|
-
- This project is part of `node_modules/ti2` in the container environment
|
|
14
|
-
- **NEVER run npm, node, or npx commands on the host machine**
|
|
15
|
-
- Always use: `docker exec ti2 bash -c "cd /ti2 && <command>"`
|
|
16
|
-
- Example for npm install: `docker exec ti2 bash -c "cd /ti2 && npm install <package>"`
|
|
17
|
-
|
|
18
|
-
2. **Testing Rules**:
|
|
19
|
-
- Tests MUST be run inside the Docker container
|
|
20
|
-
- Example: `docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"`
|
|
21
|
-
- Use `test/utils.js` for mocking the environment
|
|
22
|
-
- Review test utility implementations before writing/editing tests
|
|
23
|
-
|
|
24
|
-
3. **Background Jobs**:
|
|
25
|
-
- Jobs sent to the queue run on a separate worker thread/instance
|
|
26
|
-
- Use `worker/queue.js` `listJobs` function to check job status
|
|
27
|
-
- Workers are always assumed to be running
|
|
28
|
-
|
|
29
|
-
4. **Code Confidence**: Do not make changes until 95% confident. Ask follow-up questions if uncertain.
|
|
30
|
-
|
|
31
|
-
## Public Repo Privacy
|
|
32
|
-
|
|
33
|
-
- `ti2` is open source. Before opening or updating public PRs, review summaries, inline comments, release notes, or commit messages, scrub internal TourConnect service names and private implementation details.
|
|
34
|
-
- Do not mention internal services such as `pyfilematch`, `filematch`, `tcoutlook`, private frontend names, private trace URLs, private customer identifiers, or internal deployment architecture in public GitHub text.
|
|
35
|
-
- Use generic wording such as `upstream caller`, `internal catalog caller`, `private frontend`, `caller-side timeout`, or `internal workflow` when context is needed.
|
|
36
|
-
- After editing public GitHub text, search the PR body, issue comments, review comments, and review summaries for internal service names before handoff.
|
|
37
|
-
|
|
38
|
-
## Common Development Commands
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
# Run tests (inside Docker container)
|
|
42
|
-
docker exec ti2 bash -c "cd /ti2 && npm test"
|
|
43
|
-
docker exec ti2 bash -c "cd /ti2 && npx jest [test-file] --forceExit"
|
|
44
|
-
|
|
45
|
-
# Run specific test file
|
|
46
|
-
docker exec ti2 bash -c "cd /ti2 && npx jest controllers/__tests__/bookings.js --forceExit"
|
|
47
|
-
|
|
48
|
-
# Database migrations
|
|
49
|
-
docker exec ti2 bash -c "cd /ti2 && npm run sequelize db:migrate"
|
|
50
|
-
|
|
51
|
-
# Generate documentation
|
|
52
|
-
docker exec ti2 bash -c "cd /ti2 && npm run doc"
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
## Architecture
|
|
56
|
-
|
|
57
|
-
### Core Components
|
|
58
|
-
|
|
59
|
-
1. **Main Entry Point** (`index.js`):
|
|
60
|
-
- Initializes Express server on port 10010 (default)
|
|
61
|
-
- Sets up Swagger documentation at `/api-docs`
|
|
62
|
-
- Handles plugin instantiation and middleware
|
|
63
|
-
- Implements caching mechanism
|
|
64
|
-
- Manages background job processing via `worker/queue.js`
|
|
65
|
-
|
|
66
|
-
2. **API Definition** (`api.yml`):
|
|
67
|
-
- OpenAPI 3.0.0 specification
|
|
68
|
-
- Defines all endpoints and schemas
|
|
69
|
-
- JWT Bearer authentication
|
|
70
|
-
|
|
71
|
-
3. **Plugin System**:
|
|
72
|
-
- Two types: Integration Plugins (connect systems) and App Plugins (value-added tools)
|
|
73
|
-
- Plugins are instantiated with cache, axios, events, and configuration
|
|
74
|
-
- Plugin schemas are merged with main API schema
|
|
75
|
-
- Each plugin gets its own routes under `/apps/{pluginName}`
|
|
76
|
-
|
|
77
|
-
4. **Controllers** (`controllers/`):
|
|
78
|
-
- `app.js`: Core application controller
|
|
79
|
-
- `bookings.js`: Booking operations (searchProducts, createBooking, etc.)
|
|
80
|
-
- `admin.js`: Administrative functions
|
|
81
|
-
- `user.js`: User management
|
|
82
|
-
- `ping.js`: Health check endpoints
|
|
83
|
-
- `allotment.js`: Allotment management
|
|
84
|
-
|
|
85
|
-
5. **Database Models** (`models/`):
|
|
86
|
-
- Sequelize ORM with MySQL
|
|
87
|
-
- Key models: Integration, User, UserAppKey, CronJobs, ApiCronJobs
|
|
88
|
-
- Migrations in `migrations/` directory
|
|
89
|
-
|
|
90
|
-
6. **Worker System** (`worker/`):
|
|
91
|
-
- Bull queue for background job processing
|
|
92
|
-
- Redis-backed job queue
|
|
93
|
-
- Supports plugin jobs, API jobs, and callback jobs
|
|
94
|
-
- Multi-worker support with throng
|
|
95
|
-
|
|
96
|
-
7. **Authentication** (`auth/authHandler.js`):
|
|
97
|
-
- JWT-based authentication
|
|
98
|
-
- Bearer token scheme
|
|
99
|
-
|
|
100
|
-
### Key Features
|
|
101
|
-
|
|
102
|
-
- **Caching**: Redis-based caching with configurable TTL
|
|
103
|
-
- **Event System**: EventEmitter2 for plugin communication
|
|
104
|
-
- **Background Jobs**: Async job processing with Bull/Redis
|
|
105
|
-
- **Cron Jobs**: Scheduled task execution
|
|
106
|
-
- **Multi-tenancy**: Support for multiple integrations/plugins
|
|
107
|
-
|
|
108
|
-
### Plugin Development
|
|
109
|
-
|
|
110
|
-
Plugins must implement standard methods:
|
|
111
|
-
- `validateToken`: Token validation
|
|
112
|
-
- `tokenTemplate`: Token configuration template
|
|
113
|
-
- Booking methods: `searchProducts`, `searchAvailability`, `createBooking`, `cancelBooking`
|
|
114
|
-
- Content methods: `getProducts`, `getProduct`, `createProduct`, `updateProduct`
|
|
115
|
-
|
|
116
|
-
### Testing Strategy
|
|
117
|
-
|
|
118
|
-
- Jest testing framework
|
|
119
|
-
- Test fixtures in `__fixtures__/`
|
|
120
|
-
- Mock plugins in `__mocks__/`
|
|
121
|
-
- Use `test/utils.js` for creating test environments
|
|
122
|
-
- Tests organized by controller in `controllers/__tests__/`
|
|
123
|
-
|
|
124
|
-
## Database
|
|
125
|
-
|
|
126
|
-
- Sequelize ORM with MySQL/MariaDB
|
|
127
|
-
- Connection managed through `models/db.js`
|
|
128
|
-
- Migrations handled via sequelize-cli
|
|
129
|
-
- Models auto-loaded from `models/` directory
|
|
130
|
-
|
|
131
|
-
## Environment Variables
|
|
132
|
-
|
|
133
|
-
Key variables:
|
|
134
|
-
- `PORT`: Server port (default: 10010)
|
|
135
|
-
- `REDIS_URL`: Redis connection (default: redis://redis:6379)
|
|
136
|
-
- `jwtSecret`: JWT signing secret
|
|
137
|
-
- `adminKey`: Admin authentication key
|
|
138
|
-
- `WEB_CONCURRENCY`: Number of workers
|
|
139
|
-
- `MAX_JOBS_PER_WORKER`: Job concurrency per worker
|
|
140
|
-
- `SSL_INSECURE_ALLOWED_DOMAINS`: Pipe-separated list of domains where SSL certificate verification is skipped (e.g., `localhost|staging.example.com|192.168.1.100`)
|
|
141
|
-
|
|
142
|
-
Plugin-specific variables follow pattern: `ti2_{pluginName}_{setting}`
|
|
143
|
-
|
|
144
|
-
### SSL Configuration
|
|
145
|
-
|
|
146
|
-
The `SSL_INSECURE_ALLOWED_DOMAINS` environment variable allows you to specify domains where SSL certificate verification should be skipped. This is useful for:
|
|
147
|
-
- Development environments with self-signed certificates
|
|
148
|
-
- Testing against staging servers with invalid certificates
|
|
149
|
-
- Local development with HTTPS
|
|
150
|
-
|
|
151
|
-
Example usage:
|
|
152
|
-
```bash
|
|
153
|
-
# Skip SSL verification for specific domains
|
|
154
|
-
export SSL_INSECURE_ALLOWED_DOMAINS="localhost|staging.api.com|192.168.1.100"
|
|
155
|
-
|
|
156
|
-
# Multiple domains separated by pipe
|
|
157
|
-
export SSL_INSECURE_ALLOWED_DOMAINS="dev.example.com|test.example.com"
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
**Important Implementation Notes**:
|
|
161
|
-
- SSL configuration is ONLY applied to plugin axios instances (for external API calls) and worker axios instances
|
|
162
|
-
- Internal communication (callbacks, per-request axios) do NOT use SSL configuration
|
|
163
|
-
- SSL configuration uses a per-request interceptor approach for better isolation
|
|
164
|
-
- The configuration checks domains dynamically on each request
|
|
165
|
-
|
|
166
|
-
**Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.
|