ti2 1.0.99 → 1.0.102

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/.windsurfrules ADDED
@@ -0,0 +1,9 @@
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 ADDED
@@ -0,0 +1,159 @@
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
+ ## Common Development Commands
32
+
33
+ ```bash
34
+ # Run tests (inside Docker container)
35
+ docker exec ti2 bash -c "cd /ti2 && npm test"
36
+ docker exec ti2 bash -c "cd /ti2 && npx jest [test-file] --forceExit"
37
+
38
+ # Run specific test file
39
+ docker exec ti2 bash -c "cd /ti2 && npx jest controllers/__tests__/bookings.js --forceExit"
40
+
41
+ # Database migrations
42
+ docker exec ti2 bash -c "cd /ti2 && npm run sequelize db:migrate"
43
+
44
+ # Generate documentation
45
+ docker exec ti2 bash -c "cd /ti2 && npm run doc"
46
+ ```
47
+
48
+ ## Architecture
49
+
50
+ ### Core Components
51
+
52
+ 1. **Main Entry Point** (`index.js`):
53
+ - Initializes Express server on port 10010 (default)
54
+ - Sets up Swagger documentation at `/api-docs`
55
+ - Handles plugin instantiation and middleware
56
+ - Implements caching mechanism
57
+ - Manages background job processing via `worker/queue.js`
58
+
59
+ 2. **API Definition** (`api.yml`):
60
+ - OpenAPI 3.0.0 specification
61
+ - Defines all endpoints and schemas
62
+ - JWT Bearer authentication
63
+
64
+ 3. **Plugin System**:
65
+ - Two types: Integration Plugins (connect systems) and App Plugins (value-added tools)
66
+ - Plugins are instantiated with cache, axios, events, and configuration
67
+ - Plugin schemas are merged with main API schema
68
+ - Each plugin gets its own routes under `/apps/{pluginName}`
69
+
70
+ 4. **Controllers** (`controllers/`):
71
+ - `app.js`: Core application controller
72
+ - `bookings.js`: Booking operations (searchProducts, createBooking, etc.)
73
+ - `admin.js`: Administrative functions
74
+ - `user.js`: User management
75
+ - `ping.js`: Health check endpoints
76
+ - `allotment.js`: Allotment management
77
+
78
+ 5. **Database Models** (`models/`):
79
+ - Sequelize ORM with MySQL
80
+ - Key models: Integration, User, UserAppKey, CronJobs, ApiCronJobs
81
+ - Migrations in `migrations/` directory
82
+
83
+ 6. **Worker System** (`worker/`):
84
+ - Bull queue for background job processing
85
+ - Redis-backed job queue
86
+ - Supports plugin jobs, API jobs, and callback jobs
87
+ - Multi-worker support with throng
88
+
89
+ 7. **Authentication** (`auth/authHandler.js`):
90
+ - JWT-based authentication
91
+ - Bearer token scheme
92
+
93
+ ### Key Features
94
+
95
+ - **Caching**: Redis-based caching with configurable TTL
96
+ - **Event System**: EventEmitter2 for plugin communication
97
+ - **Background Jobs**: Async job processing with Bull/Redis
98
+ - **Cron Jobs**: Scheduled task execution
99
+ - **Multi-tenancy**: Support for multiple integrations/plugins
100
+
101
+ ### Plugin Development
102
+
103
+ Plugins must implement standard methods:
104
+ - `validateToken`: Token validation
105
+ - `tokenTemplate`: Token configuration template
106
+ - Booking methods: `searchProducts`, `searchAvailability`, `createBooking`, `cancelBooking`
107
+ - Content methods: `getProducts`, `getProduct`, `createProduct`, `updateProduct`
108
+
109
+ ### Testing Strategy
110
+
111
+ - Jest testing framework
112
+ - Test fixtures in `__fixtures__/`
113
+ - Mock plugins in `__mocks__/`
114
+ - Use `test/utils.js` for creating test environments
115
+ - Tests organized by controller in `controllers/__tests__/`
116
+
117
+ ## Database
118
+
119
+ - Sequelize ORM with MySQL/MariaDB
120
+ - Connection managed through `models/db.js`
121
+ - Migrations handled via sequelize-cli
122
+ - Models auto-loaded from `models/` directory
123
+
124
+ ## Environment Variables
125
+
126
+ Key variables:
127
+ - `PORT`: Server port (default: 10010)
128
+ - `REDIS_URL`: Redis connection (default: redis://redis:6379)
129
+ - `jwtSecret`: JWT signing secret
130
+ - `adminKey`: Admin authentication key
131
+ - `WEB_CONCURRENCY`: Number of workers
132
+ - `MAX_JOBS_PER_WORKER`: Job concurrency per worker
133
+ - `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`)
134
+
135
+ Plugin-specific variables follow pattern: `ti2_{pluginName}_{setting}`
136
+
137
+ ### SSL Configuration
138
+
139
+ The `SSL_INSECURE_ALLOWED_DOMAINS` environment variable allows you to specify domains where SSL certificate verification should be skipped. This is useful for:
140
+ - Development environments with self-signed certificates
141
+ - Testing against staging servers with invalid certificates
142
+ - Local development with HTTPS
143
+
144
+ Example usage:
145
+ ```bash
146
+ # Skip SSL verification for specific domains
147
+ export SSL_INSECURE_ALLOWED_DOMAINS="localhost|staging.api.com|192.168.1.100"
148
+
149
+ # Multiple domains separated by pipe
150
+ export SSL_INSECURE_ALLOWED_DOMAINS="dev.example.com|test.example.com"
151
+ ```
152
+
153
+ **Important Implementation Notes**:
154
+ - SSL configuration is ONLY applied to plugin axios instances (for external API calls) and worker axios instances
155
+ - Internal communication (callbacks, per-request axios) do NOT use SSL configuration
156
+ - SSL configuration uses a per-request interceptor approach for better isolation
157
+ - The configuration checks domains dynamically on each request
158
+
159
+ **Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.
@@ -0,0 +1,76 @@
1
+ /* globals describe it expect beforeAll afterAll afterEach */
2
+
3
+ const cache = require('../cache');
4
+
5
+ describe('cache', () => {
6
+ const testPluginName = 'testPlugin';
7
+ const testKeys = [];
8
+
9
+ afterEach(async () => {
10
+ // Clean up test keys
11
+ for (const key of testKeys) {
12
+ await cache.drop({ pluginName: testPluginName, key });
13
+ }
14
+ testKeys.length = 0;
15
+ });
16
+
17
+
18
+ describe('scan', () => {
19
+ it('should return keys matching the pattern without plugin prefix', async () => {
20
+ // Save some test keys
21
+ const keys = ['product:123', 'product:456', 'booking:789'];
22
+ for (const key of keys) {
23
+ testKeys.push(key);
24
+ await cache.save({ pluginName: testPluginName, key, value: { test: true } });
25
+ }
26
+
27
+ // Scan for product keys
28
+ const result = await cache.scan({ pluginName: testPluginName, pattern: 'product:*' });
29
+
30
+ expect(result).toHaveLength(2);
31
+ expect(result).toContain('product:123');
32
+ expect(result).toContain('product:456');
33
+ expect(result).not.toContain('booking:789');
34
+ });
35
+
36
+ it('should return empty array when no keys match', async () => {
37
+ const result = await cache.scan({ pluginName: testPluginName, pattern: 'nonexistent:*' });
38
+
39
+ expect(result).toEqual([]);
40
+ });
41
+
42
+ it('should not include keys from other plugins', async () => {
43
+ const otherPluginName = 'otherPlugin';
44
+
45
+ // Save keys for both plugins
46
+ await cache.save({ pluginName: testPluginName, key: 'shared:key1', value: { test: true } });
47
+ await cache.save({ pluginName: otherPluginName, key: 'shared:key2', value: { test: true } });
48
+ testKeys.push('shared:key1');
49
+
50
+ // Scan should only return keys for the specified plugin
51
+ const result = await cache.scan({ pluginName: testPluginName, pattern: 'shared:*' });
52
+
53
+ expect(result).toHaveLength(1);
54
+ expect(result).toContain('shared:key1');
55
+ expect(result).not.toContain('shared:key2');
56
+
57
+ // Cleanup other plugin key
58
+ await cache.drop({ pluginName: otherPluginName, key: 'shared:key2' });
59
+ });
60
+
61
+ it('should handle wildcard patterns correctly', async () => {
62
+ const keys = ['cache:v1:product:1', 'cache:v1:product:2', 'cache:v2:product:1'];
63
+ for (const key of keys) {
64
+ testKeys.push(key);
65
+ await cache.save({ pluginName: testPluginName, key, value: { test: true } });
66
+ }
67
+
68
+ // Scan with nested wildcard
69
+ const result = await cache.scan({ pluginName: testPluginName, pattern: 'cache:v1:*' });
70
+
71
+ expect(result).toHaveLength(2);
72
+ expect(result).toContain('cache:v1:product:1');
73
+ expect(result).toContain('cache:v1:product:2');
74
+ });
75
+ });
76
+ });
package/cache.js CHANGED
@@ -93,6 +93,26 @@ const drop = async ({
93
93
 
94
94
  const keys = async () => cache.keys('*');
95
95
 
96
+ const scan = ({
97
+ pluginName,
98
+ pattern,
99
+ count = 1000,
100
+ }) => new Promise((resolve, reject) => {
101
+ const fullPattern = `${pluginName}:${pattern}`;
102
+ const foundKeys = [];
103
+ const prefixLength = pluginName.length + 1; // +1 for the colon
104
+
105
+ const stream = cache.scanStream({ match: fullPattern, count });
106
+ stream.on('data', keys => {
107
+ keys.forEach(key => {
108
+ // Return keys without the plugin prefix
109
+ foundKeys.push(key.slice(prefixLength));
110
+ });
111
+ });
112
+ stream.on('end', () => resolve(foundKeys));
113
+ stream.on('error', reject);
114
+ });
115
+
96
116
  module.exports = {
97
117
  cache,
98
118
  save,
@@ -100,4 +120,5 @@ module.exports = {
100
120
  drop,
101
121
  get,
102
122
  keys,
123
+ scan,
103
124
  };
@@ -285,7 +285,7 @@ describe('user: bookings controller - searchProducts', () => {
285
285
  expect(jobData.method).toBe(expectedPluginMethod);
286
286
  expect(jobData.token).toBeDefined(); // Token passed to the job
287
287
  expect(jobData.payload.userId).toBe(testUserId);
288
- expect(jobData.payload.payload).toEqual({}); // Original payload for the plugin method was empty
288
+ expect(jobData.payload.payload).toEqual({ forceRefresh: false }); // Original payload for the plugin method
289
289
  expect(jobData.postProcess.controller).toBe('bookings');
290
290
  expect(jobData.postProcess.action).toBe('$updateProductSearchCache');
291
291
  expect(jobData.postProcess.args.appKey).toBe(testAppName);
@@ -512,7 +512,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
512
512
  method: expectedPluginMethodName,
513
513
  token: expect.objectContaining({ ttlForProducts: shortTTRTokenConfig.ttlForProducts }),
514
514
  payload: expect.objectContaining({
515
- payload: {}, // originalRequestBody was empty for the product search
515
+ payload: { forceRefresh: false }, // originalRequestBody was empty for the product search
516
516
  userId: testUserId,
517
517
  }),
518
518
  postProcess: expect.objectContaining({
@@ -168,4 +168,86 @@ describe('user: bookings controller', () => {
168
168
  expect(plugins[0].createBooking.mock.calls[0][0].payload).toEqual(payload);
169
169
  expect(plugins[0].createBooking.mock.calls[0][0].token).toEqual(token);
170
170
  });
171
+
172
+ describe('product search with empty cache', () => {
173
+ const productSearchCacheKey = hash({
174
+ userId,
175
+ hint: 'testingToken',
176
+ operationId: 'bookingsProductSearch',
177
+ });
178
+
179
+ beforeEach(async () => {
180
+ // Clear cache before each test
181
+ await cache.drop({ pluginName: appKey, key: productSearchCacheKey });
182
+ await cache.drop({ pluginName: appKey, key: `${productSearchCacheKey}:lock` });
183
+ await cache.drop({ pluginName: appKey, key: `${productSearchCacheKey}:lastUpdated` });
184
+ await cache.drop({ pluginName: appKey, key: `${productSearchCacheKey}:jobLock` });
185
+ jest.clearAllMocks();
186
+ });
187
+
188
+ it('should fetch from plugin when cache is empty and no search filter', async () => {
189
+ // First call: set up empty cache
190
+ plugins[0].searchProducts.mockImplementationOnce(() => ({ products: [] }));
191
+ await doApiPost({
192
+ url: `/products/${appKey}/${userId}/testingToken/search`,
193
+ token: userToken,
194
+ payload: {},
195
+ });
196
+
197
+ // Second call: cache is empty, no filter - should call plugin again
198
+ plugins[0].searchProducts.mockImplementationOnce(() => ({
199
+ products: [{ productId: '999', productName: 'Fresh Product' }],
200
+ }));
201
+ const { products } = await doApiPost({
202
+ url: `/products/${appKey}/${userId}/testingToken/search`,
203
+ token: userToken,
204
+ payload: {},
205
+ });
206
+
207
+ expect(plugins[0].searchProducts).toHaveBeenCalledTimes(2);
208
+ expect(products).toHaveLength(1);
209
+ expect(products[0].productName).toBe('Fresh Product');
210
+ });
211
+
212
+ it('should return empty when searching with filter on empty cache', async () => {
213
+ // Set up empty cache first
214
+ plugins[0].searchProducts.mockImplementationOnce(() => ({ products: [] }));
215
+ await doApiPost({
216
+ url: `/products/${appKey}/${userId}/testingToken/search`,
217
+ token: userToken,
218
+ payload: {},
219
+ });
220
+
221
+ // Search with filter on empty cache - should return empty (filter finds nothing)
222
+ const { products } = await doApiPost({
223
+ url: `/products/${appKey}/${userId}/testingToken/search`,
224
+ token: userToken,
225
+ payload: { searchInput: 'nonexistent' },
226
+ });
227
+
228
+ expect(products).toHaveLength(0);
229
+ });
230
+
231
+ it('should return cached products when cache has data', async () => {
232
+ // Set up cache with products
233
+ plugins[0].searchProducts.mockImplementationOnce(() => ({
234
+ products: [{ productId: '123', productName: 'Cached Product' }],
235
+ }));
236
+ await doApiPost({
237
+ url: `/products/${appKey}/${userId}/testingToken/search`,
238
+ token: userToken,
239
+ payload: {},
240
+ });
241
+
242
+ // Second call - should return cached data
243
+ const { products } = await doApiPost({
244
+ url: `/products/${appKey}/${userId}/testingToken/search`,
245
+ token: userToken,
246
+ payload: {},
247
+ });
248
+
249
+ expect(products).toHaveLength(1);
250
+ expect(products[0].productName).toBe('Cached Product');
251
+ });
252
+ });
171
253
  });
@@ -138,8 +138,8 @@ const $bookingsProductSearch = plugins => async ({
138
138
  forceRefresh = false,
139
139
  } = originalRequestBody;
140
140
 
141
- // Payload for the plugin function (func) - omit controller-specific flags
142
- const payloadForPlugin = R.omit(['forceRefresh'], originalRequestBody);
141
+ // Payload for the plugin function (func) - pass forceRefresh so plugins can trigger background cache rebuilds
142
+ const payloadForPlugin = { ...R.omit(['forceRefresh'], originalRequestBody), forceRefresh };
143
143
 
144
144
  const app = plugins.find(({ name }) => name === appKey);
145
145
  assert(userId, 'userId is required');
@@ -212,48 +212,47 @@ const $bookingsProductSearch = plugins => async ({
212
212
 
213
213
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
214
214
  if (initialActualCacheContent && initialActualCacheContent.products) {
215
- // Effective staleness check for deciding if a background refresh is needed.
216
- const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
217
-
218
- if (!isEffectivelyStale || hasPluginExecutionLock) {
219
- // Cache is fresh OR (is stale BUT a plugin execution is already in progress for refresh): Serve from cache.
220
- const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
221
- return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
222
- } else {
223
- // Cache is effectively stale AND no plugin execution is currently in progress.
224
- // Try to queue a background job.
225
- const hasJobQueueLock = await app.cache.get({ key: jobQueueLockKey });
226
-
227
- if (hasJobQueueLock) {
228
- // A background job has recently been queued by another request. Serve stale data.
215
+ const cacheIsEmpty = !initialActualCacheContent.products.length;
216
+ const trimmedSearch = (searchInput || '').trim();
217
+ const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
218
+
219
+ // If cache is empty and no search filter, skip to case 4 to fetch fresh data
220
+ const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
221
+ if (!shouldSkipEmptyCache) {
222
+ const returnCachedResults = () => {
229
223
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
230
224
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
231
- } else {
232
- // No job queue lock. Set one, then queue the job, then serve stale data.
233
- await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 }); // Lock for 60 seconds
225
+ };
234
226
 
235
- const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
236
-
237
- const pluginMethodPayload = {
238
- payload: payloadForPlugin,
239
- userId,
240
- };
241
- const jobData = {
242
- type: 'plugin',
243
- pluginName: appKey,
244
- method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
245
- token,
246
- payload: pluginMethodPayload,
247
- postProcess: {
248
- controller: 'bookings',
249
- action: '$updateProductSearchCache',
250
- args: { appKey, userId, hint },
251
- },
252
- };
253
- await addJob(jobData, { removeOnComplete: true });
254
-
255
- return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
227
+ const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
228
+
229
+ // Cache is fresh or plugin execution in progress: serve from cache
230
+ if (!isEffectivelyStale || hasPluginExecutionLock) {
231
+ return returnCachedResults();
232
+ }
233
+
234
+ // Cache is stale - check if background job already queued
235
+ const hasJobQueueLock = await app.cache.get({ key: jobQueueLockKey });
236
+ if (hasJobQueueLock) {
237
+ return returnCachedResults();
256
238
  }
239
+
240
+ // Queue background refresh job and serve stale data
241
+ await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 });
242
+ await addJob({
243
+ type: 'plugin',
244
+ pluginName: appKey,
245
+ method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
246
+ token,
247
+ payload: { payload: payloadForPlugin, userId },
248
+ postProcess: {
249
+ controller: 'bookings',
250
+ action: '$updateProductSearchCache',
251
+ args: { appKey, userId, hint },
252
+ },
253
+ }, { removeOnComplete: true });
254
+
255
+ return returnCachedResults();
257
256
  }
258
257
  }
259
258
 
package/index.js CHANGED
@@ -89,6 +89,7 @@ module.exports = async ({
89
89
  get: args => cache.get({ ...args, pluginName }),
90
90
  getOrExec: args => cache.getOrExec({ ...args, pluginName }),
91
91
  save: args => cache.save({ ...args, pluginName }),
92
+ scan: args => cache.scan({ ...args, pluginName }),
92
93
  },
93
94
  axios: pluginAxios,
94
95
  events: ti2Events,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.99",
3
+ "version": "1.0.102",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {