ti2 1.0.95 → 1.0.96
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/controllers/README.md +70 -1
- package/controllers/__tests__/bookings-searchProducts.js +533 -0
- package/controllers/__tests__/bookings.js +28 -276
- package/controllers/app.js +11 -8
- package/controllers/bookings.js +188 -108
- package/package.json +1 -1
- package/test/utils.js +36 -35
- package/worker/__tests__/job-plugin.js +207 -0
- package/worker/index.js +193 -121
- package/worker/queue.js +1 -0
package/controllers/README.md
CHANGED
|
@@ -1 +1,70 @@
|
|
|
1
|
-
|
|
1
|
+
# Bookings Controller Caching and Locking Strategy
|
|
2
|
+
|
|
3
|
+
The `$bookingsProductSearch` function in `controllers/bookings.js` implements a sophisticated caching and locking strategy to optimize product searches, manage stale data, and prevent redundant operations. This document outlines its core flow and the locking mechanisms involved.
|
|
4
|
+
|
|
5
|
+
## Core Flow of `$bookingsProductSearch`
|
|
6
|
+
|
|
7
|
+
1. **Initialization**:
|
|
8
|
+
* Retrieves user, application (`appKey`), and token details (including `hint`).
|
|
9
|
+
* Determines the specific plugin function to call for product search (e.g., `searchProducts` or `searchProductsForItinerary`).
|
|
10
|
+
* Calculates a `cacheKey` based on `userId`, `hint`, and a static `operationId` ('bookingsProductSearch').
|
|
11
|
+
* Defines two distinct lock keys derived from this `cacheKey`:
|
|
12
|
+
* `pluginExecutionLockKey` (resolves to `${cacheKey}:lock`): Used to serialize direct calls to the plugin.
|
|
13
|
+
* `jobQueueLockKey` (resolves to `${cacheKey}:jobLock`): Used to prevent multiple submissions of background refresh jobs.
|
|
14
|
+
* Fetches the current cache content (`initialActualCacheContent`) and its `lastUpdated` timestamp.
|
|
15
|
+
* Determines if the cache is stale (`isStaleByTTR`) based on the Time-To-Refresh (`ttr`) value from the token or plugin settings.
|
|
16
|
+
* Checks for a `doNotCallPluginForProducts` flag (from token or plugin settings) and whether a `pluginExecutionLockKey` is currently active.
|
|
17
|
+
|
|
18
|
+
2. **Request Handling Logic (Simplified Order):**
|
|
19
|
+
|
|
20
|
+
* **Condition 1: `doNotCallPluginForProducts` is true AND NOT `forceRefresh`**:
|
|
21
|
+
* If this flag is set and the request is not a forced refresh, the system serves data directly from the cache if available.
|
|
22
|
+
* If the cache is empty, it returns an empty product list.
|
|
23
|
+
* No plugin calls are made, and no background jobs are queued in this path.
|
|
24
|
+
|
|
25
|
+
* **Condition 2: `forceRefresh` is true**:
|
|
26
|
+
* The system attempts to fetch fresh data directly from the plugin.
|
|
27
|
+
* The `fetchFromPluginAndCache` helper function is invoked. This function:
|
|
28
|
+
* Sets the `pluginExecutionLockKey` before calling the plugin to prevent other concurrent direct calls.
|
|
29
|
+
* Calls the plugin's product search method.
|
|
30
|
+
* If the plugin returns valid products, these are saved to the cache (both `cacheKey` for data and `${cacheKey}:lastUpdated` for timestamp).
|
|
31
|
+
* Drops the `pluginExecutionLockKey` after completion.
|
|
32
|
+
* The (potentially filtered) results from the plugin are returned to the client.
|
|
33
|
+
|
|
34
|
+
* **Condition 3: Cache Exists AND NOT `forceRefresh`**:
|
|
35
|
+
* **Sub-condition 3a: Cache is fresh OR `pluginExecutionLockKey` is active**:
|
|
36
|
+
* If the cache is not stale according to its TTR, or if a direct plugin execution (like a `forceRefresh` or initial population) is already in progress (indicated by an active `pluginExecutionLockKey`), the system serves data from the existing cache.
|
|
37
|
+
* **Sub-condition 3b: Cache is stale AND no `pluginExecutionLockKey` is active**:
|
|
38
|
+
* The system checks for an active `jobQueueLockKey`.
|
|
39
|
+
* If `jobQueueLockKey` IS active: It implies that another request very recently detected the stale cache and has already queued a background refresh job. The current request serves the stale data from the cache without queueing another job.
|
|
40
|
+
* If `jobQueueLockKey` is NOT active: This request is the first (or among the first) to find the stale cache without an ongoing direct refresh or a recently queued job. It will:
|
|
41
|
+
1. Set the `jobQueueLockKey` with a short TTL (e.g., 60 seconds).
|
|
42
|
+
2. Queue a background job using `addJob`. This job will eventually call the plugin to refresh the data and then use `$updateProductSearchCache` to update the cache.
|
|
43
|
+
3. Serve the stale data from the cache to the current client.
|
|
44
|
+
|
|
45
|
+
* **Condition 4: No Cache Content AND NOT `forceRefresh` AND NOT `doNotCallPluginForProducts`**:
|
|
46
|
+
* If there's no existing cache content and none of the preceding conditions (like `forceRefresh` or `doNotCallPluginForProducts`) were met, the system needs to populate the cache.
|
|
47
|
+
* It calls `fetchFromPluginAndCache` (which sets `pluginExecutionLockKey`, calls the plugin, caches results, and drops the lock) to get initial data.
|
|
48
|
+
* The (potentially filtered) results are returned to the client.
|
|
49
|
+
|
|
50
|
+
## Locking Mechanisms Explained
|
|
51
|
+
|
|
52
|
+
The system uses two types of locks, both based on the primary `cacheKey`, to manage concurrency and prevent redundant operations:
|
|
53
|
+
|
|
54
|
+
1. **`pluginExecutionLockKey` (derived from `${cacheKey}:lock`)**:
|
|
55
|
+
* **Purpose**: To prevent multiple simultaneous *direct calls* to the external plugin for the same product search parameters. This is crucial during `forceRefresh` scenarios or when the cache is being populated for the first time by concurrent requests.
|
|
56
|
+
* **Behavior**:
|
|
57
|
+
* This lock is set by the `fetchFromPluginAndCache` helper function immediately before it makes an actual call to the plugin's `searchProducts` (or equivalent) method.
|
|
58
|
+
* It is configured with a TTL (e.g., 120 seconds) to ensure it automatically expires if the process holding the lock crashes or fails to release it.
|
|
59
|
+
* The lock is explicitly dropped by `fetchFromPluginAndCache` after the plugin call completes (whether successfully or with an error).
|
|
60
|
+
* Other parts of the main logic (e.g., in Condition 3a) check for the presence of this lock (`hasPluginExecutionLock`). If active, it signals that a direct plugin data fetch is already in progress, prompting the current request to, for example, serve stale data or wait, rather than initiating another direct plugin call.
|
|
61
|
+
|
|
62
|
+
2. **`jobQueueLockKey` (derived from `${cacheKey}:jobLock`)**:
|
|
63
|
+
* **Purpose**: To prevent the submission of multiple identical *background refresh jobs* by nearly simultaneous requests when the cache is found to be stale and no direct plugin execution (covered by `pluginExecutionLockKey`) is active.
|
|
64
|
+
* **Behavior**:
|
|
65
|
+
* This lock is checked specifically when the cache is determined to be effectively stale (`isEffectivelyStale`) and no `pluginExecutionLockKey` is currently active (Condition 3b).
|
|
66
|
+
* If the `jobQueueLockKey` is NOT found in the cache, the current request assumes responsibility for queuing the refresh job. It sets this lock with a short TTL (e.g., 60 seconds) *before* calling `addJob`.
|
|
67
|
+
* If the `jobQueueLockKey` IS found, it indicates that another request has very recently detected the stale state and has already taken action to queue the refresh job. The current request will then proceed to serve stale data without attempting to queue another job.
|
|
68
|
+
* This mechanism ensures that even if numerous requests detect stale data at virtually the same moment, only one of them will succeed in setting the `jobQueueLockKey` and thereby be responsible for queueing the single background refresh task.
|
|
69
|
+
|
|
70
|
+
These two locks work in tandem: `pluginExecutionLockKey` manages contention for direct, immediate plugin interactions, while `jobQueueLockKey` manages contention for initiating background refresh tasks when stale data is being served. This dual-lock strategy helps maintain system performance and avoids overwhelming external plugin services or the background job queue.
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/* globals beforeAll describe it expect jest beforeEach */
|
|
2
|
+
|
|
3
|
+
const chance = require('chance').Chance();
|
|
4
|
+
const hash = require('object-hash');
|
|
5
|
+
const cache = require('../../cache');
|
|
6
|
+
// Remove direct import of listJobs, jobStatus as we'll mock addJob
|
|
7
|
+
// const { listJobs, jobStatus } = require('../../worker/queue');
|
|
8
|
+
|
|
9
|
+
// Mock the worker/queue module
|
|
10
|
+
jest.mock('../../worker/queue', () => ({
|
|
11
|
+
...jest.requireActual('../../worker/queue'), // Import and retain default behavior
|
|
12
|
+
addJob: jest.fn().mockResolvedValue({ id: 'mockJobId' }), // Mock addJob
|
|
13
|
+
// Provide mock for jobStatus as it might be called by other parts of the code or tests
|
|
14
|
+
jobStatus: jest.fn().mockResolvedValue({ status: 'completed', progress: 100, returnValue: null }),
|
|
15
|
+
}));
|
|
16
|
+
const { addJob, jobStatus: mockJobStatus } = require('../../worker/queue'); // Now addJob is the mock
|
|
17
|
+
|
|
18
|
+
const testUtils = require('../../test/utils'); // Require the module itself
|
|
19
|
+
|
|
20
|
+
// Global setup for the entire test file
|
|
21
|
+
let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
// Initialize utils once for the entire test file
|
|
25
|
+
// Ensure all plugins needed across different describe blocks are listed here.
|
|
26
|
+
globalUtils = await testUtils({ plugins: ['lockTestPlugin', 'travelgate'] });
|
|
27
|
+
globalDoApiPost = globalUtils.doApiPost;
|
|
28
|
+
globalPlugins = globalUtils.plugins; // Array of instantiated plugins from the app instance
|
|
29
|
+
globalSqldb = globalUtils.sqldb;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
// Close DB connection after all tests in this file have run
|
|
34
|
+
if (globalSqldb && globalSqldb.sequelize && globalSqldb.sequelize.close) {
|
|
35
|
+
await globalSqldb.sequelize.close();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('user: bookings controller - searchProducts', () => {
|
|
40
|
+
let doApiGet;
|
|
41
|
+
let doApiPost;
|
|
42
|
+
let travelgatePlugin; // Specific plugin instance for this suite
|
|
43
|
+
let userToken;
|
|
44
|
+
let testAppName;
|
|
45
|
+
let testUserId;
|
|
46
|
+
let testPluginToken;
|
|
47
|
+
let testHint;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
// Use globally initialized utilities and plugins
|
|
51
|
+
doApiGet = globalUtils.doApiGet;
|
|
52
|
+
doApiPost = globalUtils.doApiPost;
|
|
53
|
+
travelgatePlugin = globalPlugins.find(p => p.name === 'travelgate');
|
|
54
|
+
if (!travelgatePlugin) {
|
|
55
|
+
throw new Error("travelgatePlugin not found. Ensure it's included in global testUtils setup.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Setup a new app, user, and integration using globalUtils.appSetup
|
|
59
|
+
// This appName ('travelgate') should match the plugin name we want to test.
|
|
60
|
+
const setupData = await globalUtils.appSetup({
|
|
61
|
+
appName: 'travelgate',
|
|
62
|
+
// userId will be generated by appSetup if not provided
|
|
63
|
+
// token and tokenHint will be default from appSetup unless specified
|
|
64
|
+
});
|
|
65
|
+
testAppName = setupData.newApp.name; // Should be 'travelgate'
|
|
66
|
+
testUserId = setupData.userId;
|
|
67
|
+
testPluginToken = setupData.token; // Token config for the 'travelgate' integration
|
|
68
|
+
testHint = setupData.hint; // Hint for this 'travelgate' integration
|
|
69
|
+
|
|
70
|
+
userToken = globalUtils.createUserToken(testUserId); // JWT for API calls
|
|
71
|
+
expect(userToken).toBeTruthy();
|
|
72
|
+
|
|
73
|
+
// Drop cache keys for this specific context
|
|
74
|
+
const cacheKey = hash({
|
|
75
|
+
appKey: testAppName,
|
|
76
|
+
userId: testUserId,
|
|
77
|
+
hint: testHint, // Use the hint from the default integration
|
|
78
|
+
operationId: 'bookingsProductSearch',
|
|
79
|
+
});
|
|
80
|
+
await cache.drop({
|
|
81
|
+
pluginName: testAppName,
|
|
82
|
+
key: cacheKey,
|
|
83
|
+
});
|
|
84
|
+
await cache.drop({
|
|
85
|
+
pluginName: testAppName,
|
|
86
|
+
key: `${cacheKey}:lock`,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
// Clear mocks relevant to this suite.
|
|
92
|
+
if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockClear) {
|
|
93
|
+
travelgatePlugin.searchProducts.mockClear();
|
|
94
|
+
}
|
|
95
|
+
if (addJob && addJob.mockClear) { // Ensure addJob is cleared if tests in this suite might call it
|
|
96
|
+
addJob.mockClear();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('searchProducts', () => {
|
|
101
|
+
it('should be able to get bookings products for most users without special setup: no cache', async () => {
|
|
102
|
+
const payload = {};
|
|
103
|
+
const { products } = await doApiPost({
|
|
104
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
105
|
+
token: userToken,
|
|
106
|
+
payload,
|
|
107
|
+
});
|
|
108
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalled();
|
|
109
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
110
|
+
expect(products.length).toBe(2);
|
|
111
|
+
expect(products[0].options.length).toBe(1);
|
|
112
|
+
expect(products[1].options.length).toBe(2);
|
|
113
|
+
});
|
|
114
|
+
it('should be able to get booking products: no cache, forceRefresh', async () => {
|
|
115
|
+
// NOTE: we SHOULD NOT need to remove the cache first, since we are forceRefreshing, we are testing the endpoint get's called while having a cache created
|
|
116
|
+
const payload = {
|
|
117
|
+
forceRefresh: true,
|
|
118
|
+
};
|
|
119
|
+
const { products } = await doApiPost({
|
|
120
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
121
|
+
token: userToken,
|
|
122
|
+
payload,
|
|
123
|
+
});
|
|
124
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalled();
|
|
125
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
126
|
+
expect(products.length).toBe(2);
|
|
127
|
+
expect(products[0].options.length).toBe(1);
|
|
128
|
+
expect(products[1].options.length).toBe(2);
|
|
129
|
+
expect(travelgatePlugin.searchProducts.mock.calls[0][0].token).toEqual(testPluginToken);
|
|
130
|
+
});
|
|
131
|
+
describe('cache exists and is valid', () => {
|
|
132
|
+
it('should be able to get booking products: using cache', async () => {
|
|
133
|
+
const payload = {};
|
|
134
|
+
const { products } = await doApiPost({
|
|
135
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
136
|
+
token: userToken,
|
|
137
|
+
payload,
|
|
138
|
+
});
|
|
139
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
140
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
141
|
+
expect(products.length).toBe(2);
|
|
142
|
+
expect(products[0].options.length).toBe(1);
|
|
143
|
+
expect(products[1].options.length).toBe(2);
|
|
144
|
+
});
|
|
145
|
+
it('should be able to get booking products with searchInput', async () => {
|
|
146
|
+
|
|
147
|
+
const payload = {
|
|
148
|
+
searchInput: 'Transfer from Sydney Harbor to Hilton Hotel',
|
|
149
|
+
};
|
|
150
|
+
const { products } = await doApiPost({
|
|
151
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
152
|
+
token: userToken,
|
|
153
|
+
payload,
|
|
154
|
+
});
|
|
155
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
156
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
157
|
+
expect(products.length).toBe(1);
|
|
158
|
+
expect(products[0].productName).toBe('Davids');
|
|
159
|
+
expect(products[0].options.length).toBe(1);
|
|
160
|
+
expect(products[0].options[0].optionName).toBe('Transfer from Sydney Harbor Bridge to Hilton Hotel');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('doNotCallPluginForProducts flag', () => {
|
|
164
|
+
const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
|
|
165
|
+
beforeAll(async () => {
|
|
166
|
+
// Create a specific integration for this test suite
|
|
167
|
+
const newIntegrationContent = {
|
|
168
|
+
endpoint: 'https://api.travelgatex.com', // Can be any valid URL
|
|
169
|
+
apiKey: chance.guid(),
|
|
170
|
+
client: 'tourconnect', // Or any other fields your plugin might expect
|
|
171
|
+
doNotCallPluginForProducts: true,
|
|
172
|
+
};
|
|
173
|
+
// Use globalUtils.appSetup to create this specific integration (UserAppKey)
|
|
174
|
+
await globalUtils.appSetup({
|
|
175
|
+
appName: testAppName, // Still 'travelgate' context for the plugin
|
|
176
|
+
userId: testUserId, // Same user
|
|
177
|
+
tokenHint: doNotCallHint,
|
|
178
|
+
token: newIntegrationContent,
|
|
179
|
+
});
|
|
180
|
+
// Clear mocks that might have been called during appSetup
|
|
181
|
+
if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('productSearch should not call the plugin', async () => {
|
|
185
|
+
// first time call, no cache expect no plugin call and empty products
|
|
186
|
+
let products;
|
|
187
|
+
await doApiPost({
|
|
188
|
+
url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
|
|
189
|
+
token: userToken,
|
|
190
|
+
payload: {},
|
|
191
|
+
}).then(({ products: p }) => {
|
|
192
|
+
products = p;
|
|
193
|
+
});
|
|
194
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
195
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
196
|
+
expect(products.length).toBe(0);
|
|
197
|
+
|
|
198
|
+
});
|
|
199
|
+
it('a forceRefresh should trigger the call to the plugin', async() => {
|
|
200
|
+
// second time call, forceRefresh and still expect plugin call
|
|
201
|
+
let products; // Define products here to ensure it's in scope for the then block
|
|
202
|
+
await doApiPost({
|
|
203
|
+
url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
|
|
204
|
+
token: userToken,
|
|
205
|
+
payload: { forceRefresh: true },
|
|
206
|
+
}).then(({ products: p }) => {
|
|
207
|
+
products = p; // Assign to the outer scoped 'products'
|
|
208
|
+
});
|
|
209
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalled();
|
|
210
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
211
|
+
expect(products.length).toBe(2);
|
|
212
|
+
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('cache TTR and lock mechanism', () => {
|
|
216
|
+
const ttrTestHint = 'ttr-test'; // This hint is for UserAppKey within 'travelgate' appName
|
|
217
|
+
const shortTTRToken = {
|
|
218
|
+
endpoint: 'https://api.travelgatex.com', // Can be any valid URL
|
|
219
|
+
apiKey: chance.guid(),
|
|
220
|
+
client: 'tourconnect', // Or any other fields
|
|
221
|
+
ttlForProducts: 2, // 2 seconds TTR
|
|
222
|
+
};
|
|
223
|
+
beforeAll(async () => {
|
|
224
|
+
// Create/Update UserAppKey for testAppName ('travelgate') with this specific hint and TTR token
|
|
225
|
+
await globalUtils.appSetup({
|
|
226
|
+
appName: testAppName, // 'travelgate'
|
|
227
|
+
userId: testUserId,
|
|
228
|
+
tokenHint: ttrTestHint,
|
|
229
|
+
token: shortTTRToken,
|
|
230
|
+
});
|
|
231
|
+
if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
|
|
232
|
+
});
|
|
233
|
+
describe('inside of the TTR period', () => {
|
|
234
|
+
it('first call should create the cache', async ()=> {
|
|
235
|
+
await doApiPost({
|
|
236
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
|
|
237
|
+
token: userToken,
|
|
238
|
+
payload: {},
|
|
239
|
+
});
|
|
240
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
it('inmediate call should not call the plugin mehthod', async () => {
|
|
243
|
+
// Second immediate call should use cache
|
|
244
|
+
await doApiPost({
|
|
245
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
|
|
246
|
+
token: userToken,
|
|
247
|
+
payload: {},
|
|
248
|
+
});
|
|
249
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
describe('outside of the TTR period', () => {
|
|
253
|
+
it('wait for the TTR to expire', async () => {
|
|
254
|
+
// Wait for TTR to expire (2 seconds + buffer)
|
|
255
|
+
await new Promise(resolve => {
|
|
256
|
+
setTimeout(resolve, 2100);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
|
|
260
|
+
travelgatePlugin.searchProducts.mockClear(); // Clear before action
|
|
261
|
+
addJob.mockClear(); // Clear addJob mock before this action that should trigger it
|
|
262
|
+
|
|
263
|
+
const { products } = await doApiPost({
|
|
264
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
|
|
265
|
+
token: userToken,
|
|
266
|
+
payload: {},
|
|
267
|
+
});
|
|
268
|
+
// This synchronous call should serve stale data.
|
|
269
|
+
// The plugin should NOT be called by *this* request directly as a background job is queued.
|
|
270
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
271
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
272
|
+
expect(products.length).toBe(2); // Assuming stale data (from initial cache population) is available and has 2 products
|
|
273
|
+
|
|
274
|
+
// Check that addJob was called and verify its parameters
|
|
275
|
+
expect(addJob).toHaveBeenCalledTimes(1);
|
|
276
|
+
|
|
277
|
+
const jobCall = addJob.mock.calls[0]; // Get the first (and only expected) call to addJob
|
|
278
|
+
const jobData = jobCall[0]; // First argument to addJob is the job payload
|
|
279
|
+
const jobParams = jobCall[1]; // Second argument is params like removeOnComplete
|
|
280
|
+
|
|
281
|
+
const expectedPluginMethod = travelgatePlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
|
|
282
|
+
|
|
283
|
+
expect(jobData.type).toBe('plugin');
|
|
284
|
+
expect(jobData.pluginName).toBe(testAppName); // 'travelgate'
|
|
285
|
+
expect(jobData.method).toBe(expectedPluginMethod);
|
|
286
|
+
expect(jobData.token).toBeDefined(); // Token passed to the job
|
|
287
|
+
expect(jobData.payload.userId).toBe(testUserId);
|
|
288
|
+
expect(jobData.payload.payload).toEqual({}); // Original payload for the plugin method was empty
|
|
289
|
+
expect(jobData.postProcess.controller).toBe('bookings');
|
|
290
|
+
expect(jobData.postProcess.action).toBe('$updateProductSearchCache');
|
|
291
|
+
expect(jobData.postProcess.args.appKey).toBe(testAppName);
|
|
292
|
+
expect(jobData.postProcess.args.userId).toBe(testUserId);
|
|
293
|
+
expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
|
|
294
|
+
expect(jobParams).toEqual({ removeOnComplete: true });
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
// The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('bookingsProductSearch caching - stale cache on TTR expiry', () => {
|
|
302
|
+
// This suite tests behavior when TTR expires and a refresh yields empty results,
|
|
303
|
+
// expecting stale cache to be served. It uses a real cache with a short TTL.
|
|
304
|
+
// This suite also uses the global utils and travelgatePlugin. testAppName and testUserId are from parent suite.
|
|
305
|
+
|
|
306
|
+
const staleCacheTestHint = 'stale-cache-expiry-test-hint'; // For 'travelgate' appName (testAppName)
|
|
307
|
+
const shortTtlForProducts = 2; // 2 seconds
|
|
308
|
+
const staleCacheTokenConfig = {
|
|
309
|
+
endpoint: 'https://api.travelgatex.com/stale-test', // Unique endpoint for clarity
|
|
310
|
+
apiKey: chance.guid(),
|
|
311
|
+
client: 'tourconnect-stale-test',
|
|
312
|
+
ttlForProducts: shortTtlForProducts,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
beforeEach(async () => {
|
|
316
|
+
// Clear mocks
|
|
317
|
+
if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockClear) {
|
|
318
|
+
travelgatePlugin.searchProducts.mockClear();
|
|
319
|
+
}
|
|
320
|
+
if (addJob && addJob.mockClear) {
|
|
321
|
+
addJob.mockClear();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Ensure a clean cache state for this specific hint before each test run
|
|
325
|
+
// testAppName here is 'travelgate' from the outer describe block's setup
|
|
326
|
+
const cacheKeyForTest = hash({
|
|
327
|
+
userId: testUserId, // User from the 'user: bookings controller' suite
|
|
328
|
+
hint: staleCacheTestHint,
|
|
329
|
+
operationId: 'bookingsProductSearch',
|
|
330
|
+
});
|
|
331
|
+
await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
|
|
332
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
333
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKeyForTest}:lock` });
|
|
334
|
+
|
|
335
|
+
// Setup the UserAppKey (integration) with the short TTL for products
|
|
336
|
+
// This uses the testUserId and testAppName ('travelgate') from the parent describe
|
|
337
|
+
await globalUtils.appSetup({
|
|
338
|
+
appName: testAppName,
|
|
339
|
+
userId: testUserId,
|
|
340
|
+
tokenHint: staleCacheTestHint,
|
|
341
|
+
token: staleCacheTokenConfig,
|
|
342
|
+
});
|
|
343
|
+
// Clear mocks that might have been called during appSetup
|
|
344
|
+
if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
|
|
345
|
+
if (addJob.mockClear) addJob.mockClear();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should return stale (non-empty) products when TTR expires and refresh yields empty products', async () => {
|
|
349
|
+
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
350
|
+
const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
|
|
351
|
+
|
|
352
|
+
// 1. First call: Populate the cache with initialProductsInCache
|
|
353
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
|
|
354
|
+
const { products: firstCallResult } = await doApiPost({
|
|
355
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
356
|
+
token: userToken,
|
|
357
|
+
payload: { searchInput: '' },
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(firstCallResult).toEqual(initialProductsInCache);
|
|
361
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
362
|
+
expect(addJob).not.toHaveBeenCalled(); // No job on initial population
|
|
363
|
+
travelgatePlugin.searchProducts.mockClear();
|
|
364
|
+
|
|
365
|
+
// 2. Wait for TTL to expire (shortTtlForProducts is 2s, wait 3s)
|
|
366
|
+
await new Promise(resolve => setTimeout(resolve, (shortTtlForProducts + 1) * 1000));
|
|
367
|
+
|
|
368
|
+
// 3. Second call: TTR has expired. A background job will be queued.
|
|
369
|
+
// The plugin method for the *background job* will return empty results.
|
|
370
|
+
// This mock is for the plugin call that the *worker* would make.
|
|
371
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: productsFromPluginRefresh });
|
|
372
|
+
|
|
373
|
+
const { products: secondCallResult } = await doApiPost({
|
|
374
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
375
|
+
token: userToken,
|
|
376
|
+
payload: { searchInput: '' },
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Assert that the stale data (initialProductsInCache) is returned by this synchronous call.
|
|
380
|
+
expect(secondCallResult).toEqual(initialProductsInCache);
|
|
381
|
+
expect(secondCallResult.length).toBeGreaterThan(0);
|
|
382
|
+
|
|
383
|
+
// Verify that addJob was called to queue the background refresh.
|
|
384
|
+
expect(addJob).toHaveBeenCalledTimes(1);
|
|
385
|
+
// The travelgatePlugin.searchProducts mock was for the *job*, so it shouldn't be called by the API directly here.
|
|
386
|
+
// The controller serves stale and queues job; it doesn't call plugin directly in this path.
|
|
387
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
388
|
+
|
|
389
|
+
// Note: This test verifies the immediate response serves stale data and a job is queued.
|
|
390
|
+
// It does not verify the cache state *after* the job (which would use the empty refresh)
|
|
391
|
+
// because $updateProductSearchCache currently caches empty results.
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)', () => {
|
|
397
|
+
let doApiPost;
|
|
398
|
+
let lockTestPlugin; // Specific plugin instance for this suite
|
|
399
|
+
let userToken;
|
|
400
|
+
let testAppName;
|
|
401
|
+
let testUserId;
|
|
402
|
+
let ttrTestHint;
|
|
403
|
+
const shortTTRTokenConfig = {
|
|
404
|
+
endpoint: 'https://api.travelgatex.com/lock-test',
|
|
405
|
+
apiKey: chance.guid(),
|
|
406
|
+
client: 'tourconnect-lock-test',
|
|
407
|
+
ttlForProducts: 1, // 1 second TTR for faster testing
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
beforeAll(async () => {
|
|
411
|
+
doApiPost = globalDoApiPost;
|
|
412
|
+
lockTestPlugin = globalPlugins.find(p => p.name === 'lockTestPlugin');
|
|
413
|
+
if (!lockTestPlugin) {
|
|
414
|
+
throw new Error("lockTestPlugin not found. Ensure it's included in global testUtils setup.");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
ttrTestHint = 'lock-mechanism-hint';
|
|
418
|
+
|
|
419
|
+
// Use globalUtils.appSetup to create the app, user, integration, and userAppKey
|
|
420
|
+
// This ensures the Integration 'lockTestPlugin' is created before UserAppKey.
|
|
421
|
+
// The appName here must match an actual plugin name.
|
|
422
|
+
// A unique userId is generated by appSetup if not provided.
|
|
423
|
+
const setupData = await globalUtils.appSetup({
|
|
424
|
+
appName: 'lockTestPlugin', // This will be the integrationId and plugin name
|
|
425
|
+
token: shortTTRTokenConfig, // Pass the specific token config for UserAppKey
|
|
426
|
+
tokenHint: ttrTestHint, // Pass the specific hint for UserAppKey
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
testAppName = setupData.newApp.name; // Should be 'lockTestPlugin'
|
|
430
|
+
testUserId = setupData.userId;
|
|
431
|
+
userToken = globalUtils.createUserToken(testUserId); // JWT for API calls
|
|
432
|
+
|
|
433
|
+
// The UserAppKey with shortTTRTokenConfig and ttrTestHint is now created by appSetup.
|
|
434
|
+
// No need for an additional doApiPost to create/update it here.
|
|
435
|
+
|
|
436
|
+
// Initial cache clear for this specific context. testAppName is 'lockTestPlugin'.
|
|
437
|
+
const cacheKey = hash({
|
|
438
|
+
userId: testUserId,
|
|
439
|
+
hint: ttrTestHint,
|
|
440
|
+
operationId: 'bookingsProductSearch',
|
|
441
|
+
});
|
|
442
|
+
await cache.drop({ pluginName: testAppName, key: cacheKey });
|
|
443
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
|
|
444
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` }); // pluginExecutionLock
|
|
445
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }); // jobQueueLock
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
beforeEach(async () => {
|
|
449
|
+
// Clear mocks before each test in this suite
|
|
450
|
+
addJob.mockClear(); // Reset addJob mock calls
|
|
451
|
+
if (lockTestPlugin && lockTestPlugin.searchProducts && lockTestPlugin.searchProducts.mockClear) {
|
|
452
|
+
lockTestPlugin.searchProducts.mockClear();
|
|
453
|
+
}
|
|
454
|
+
// Clear other global mocks if they were used by this suite and need resetting
|
|
455
|
+
if (mockJobStatus && mockJobStatus.mockClear) mockJobStatus.mockClear();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('multiple concurrent requests to stale cache should serve stale data and queue only one new refresh job', async () => {
|
|
459
|
+
// 1. First call: Populate the cache using the specific lockTestPlugin
|
|
460
|
+
lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod1', name: 'Initial Product' }] });
|
|
461
|
+
|
|
462
|
+
await doApiPost({
|
|
463
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // testAppName is 'lockTestApp'
|
|
464
|
+
token: userToken,
|
|
465
|
+
payload: {},
|
|
466
|
+
});
|
|
467
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
468
|
+
expect(addJob).not.toHaveBeenCalled(); // No job queued on initial population
|
|
469
|
+
lockTestPlugin.searchProducts.mockClear(); // Clear for next phase
|
|
470
|
+
|
|
471
|
+
// 2. Wait for TTR to expire (ttlForProducts is 1s, wait 1.5s)
|
|
472
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
473
|
+
|
|
474
|
+
// 3. Make multiple concurrent requests to the now stale cache
|
|
475
|
+
// Mock plugin response for the background refresh. This mock is for the job that addJob is supposed to queue.
|
|
476
|
+
lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod2', name: 'Refreshed Product' }] });
|
|
477
|
+
|
|
478
|
+
const makeRequest = () => doApiPost({
|
|
479
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
480
|
+
token: userToken,
|
|
481
|
+
payload: {},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const requestPromises = [];
|
|
485
|
+
requestPromises.push(makeRequest()); // First request hits stale cache, should queue job
|
|
486
|
+
await global.sleep(50); // Small delay to simulate near concurrency
|
|
487
|
+
requestPromises.push(makeRequest()); // Second request should also hit stale cache, but not queue another job
|
|
488
|
+
await global.sleep(50);
|
|
489
|
+
requestPromises.push(makeRequest()); // Third request
|
|
490
|
+
|
|
491
|
+
const results = await Promise.all(requestPromises);
|
|
492
|
+
|
|
493
|
+
// Assertions:
|
|
494
|
+
// a. All requests should serve stale data (the "Initial Product")
|
|
495
|
+
results.forEach(result => {
|
|
496
|
+
expect(result.products).toEqual([{ id: 'prod1', name: 'Initial Product' }]);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// b. The plugin's searchProducts method (on lockTestPlugin) should NOT have been called directly by these API requests
|
|
500
|
+
expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
|
|
501
|
+
|
|
502
|
+
// c. addJob should have been called exactly once
|
|
503
|
+
expect(addJob).toHaveBeenCalledTimes(1);
|
|
504
|
+
|
|
505
|
+
// d. Verify arguments of addJob
|
|
506
|
+
if (addJob.mock.calls.length > 0) {
|
|
507
|
+
const expectedPluginMethodName = lockTestPlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
|
|
508
|
+
expect(addJob).toHaveBeenCalledWith(
|
|
509
|
+
expect.objectContaining({
|
|
510
|
+
type: 'plugin',
|
|
511
|
+
pluginName: testAppName, // This should be 'lockTestPlugin'
|
|
512
|
+
method: expectedPluginMethodName,
|
|
513
|
+
token: expect.objectContaining({ ttlForProducts: shortTTRTokenConfig.ttlForProducts }),
|
|
514
|
+
payload: expect.objectContaining({
|
|
515
|
+
payload: {}, // originalRequestBody was empty for the product search
|
|
516
|
+
userId: testUserId,
|
|
517
|
+
}),
|
|
518
|
+
postProcess: expect.objectContaining({
|
|
519
|
+
controller: 'bookings',
|
|
520
|
+
action: '$updateProductSearchCache',
|
|
521
|
+
args: expect.objectContaining({
|
|
522
|
+
appKey: testAppName, // 'lockTestPlugin'
|
|
523
|
+
userId: testUserId,
|
|
524
|
+
hint: ttrTestHint,
|
|
525
|
+
}),
|
|
526
|
+
}),
|
|
527
|
+
}),
|
|
528
|
+
{ removeOnComplete: true }
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|