ti2 1.0.118 → 1.0.120

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,166 @@
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.
@@ -198,6 +198,26 @@ describe('user: bookings controller - searchProducts', () => {
198
198
  });
199
199
  emitSpy.mockRestore();
200
200
  });
201
+
202
+ it('includes raw scope only on legacy cache-save events', async () => {
203
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
204
+ await doApiPost({
205
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
206
+ token: userToken,
207
+ payload: { forceRefresh: true },
208
+ });
209
+ const cacheSavePayload = emitSpy.mock.calls
210
+ .filter(call => call[0] === 'bookingsProductSearch:cache:save')
211
+ .map(call => call[1])
212
+ .find(payload => payload.action === 'cache_saved');
213
+
214
+ expect(cacheSavePayload).toBeTruthy();
215
+ expect(cacheSavePayload.userId).toBe(testUserId);
216
+ expect(cacheSavePayload.hint).toBe(testHint);
217
+ expect(cacheSavePayload.userIdHash).toBeTruthy();
218
+ expect(cacheSavePayload.hintHash).toBeTruthy();
219
+ emitSpy.mockRestore();
220
+ });
201
221
  });
202
222
  describe('doNotCallPluginForProducts flag', () => {
203
223
  const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
@@ -852,6 +872,85 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
852
872
  expect(addJob).not.toHaveBeenCalled();
853
873
  });
854
874
 
875
+ it('does not save a cold optionId hydration as the full product catalog', async () => {
876
+ await clearProductSearchCache();
877
+
878
+ const scopedProducts = [{
879
+ productId: 'scoped-product',
880
+ productName: 'Scoped Product',
881
+ options: [{
882
+ optionId: 'scoped-option',
883
+ optionName: 'Scoped Option',
884
+ }],
885
+ }];
886
+ const fullCatalog = [{
887
+ productId: 'catalog-product',
888
+ productName: 'Catalog Product',
889
+ options: [{
890
+ optionId: 'catalog-option',
891
+ optionName: 'Catalog Option',
892
+ }],
893
+ }];
894
+ const emitSpy = jest.spyOn(lockTestPlugin.events, 'emit');
895
+ lockTestPlugin.searchProducts
896
+ .mockResolvedValueOnce({ products: scopedProducts })
897
+ .mockResolvedValueOnce({ products: fullCatalog });
898
+
899
+ const scopedResult = await doApiPost({
900
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
901
+ token: userToken,
902
+ payload: { optionId: ['scoped-option'] },
903
+ });
904
+ const catalogResult = await makeProductSearchRequest();
905
+
906
+ expect(scopedResult.products).toEqual(scopedProducts);
907
+ expect(catalogResult.products).toEqual(fullCatalog);
908
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(2);
909
+ expect(lockTestPlugin.searchProducts.mock.calls[0][0].payload.optionId).toEqual([
910
+ 'scoped-option',
911
+ ]);
912
+ expect(lockTestPlugin.searchProducts.mock.calls[1][0].payload.optionId).toBeUndefined();
913
+ expect(emitSpy).toHaveBeenCalledWith(
914
+ 'bookingsProductSearch:cache:decision',
915
+ expect.objectContaining({
916
+ action: 'scoped_result_not_cached',
917
+ returnedProductCount: scopedProducts.length,
918
+ returnedOptionCount: 1,
919
+ }),
920
+ );
921
+ emitSpy.mockRestore();
922
+ });
923
+
924
+ it('queues an unscoped refresh when an optionId request finds stale catalog data', async () => {
925
+ await clearProductSearchCache();
926
+ const cachedProducts = [{
927
+ productId: 'cached-product',
928
+ productName: 'Cached Product',
929
+ options: [{
930
+ optionId: 'cached-option',
931
+ optionName: 'Cached Option',
932
+ }],
933
+ }];
934
+ lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: cachedProducts });
935
+ await makeProductSearchRequest();
936
+ lockTestPlugin.searchProducts.mockClear();
937
+ addJob.mockClear();
938
+ await new Promise(resolve => {
939
+ setTimeout(resolve, 1500);
940
+ });
941
+
942
+ const result = await doApiPost({
943
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
944
+ token: userToken,
945
+ payload: { optionId: ['cached-option'] },
946
+ });
947
+
948
+ expect(result.products).toEqual(cachedProducts);
949
+ expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
950
+ expect(addJob).toHaveBeenCalledTimes(1);
951
+ expect(addJob.mock.calls[0][0].payload.payload).toEqual({ forceRefresh: false });
952
+ });
953
+
855
954
  it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
856
955
  await clearProductSearchCache();
857
956
 
@@ -52,8 +52,29 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
52
52
 
53
53
  const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
54
54
 
55
+ const productSearchSelectorFields = [
56
+ 'searchInput',
57
+ 'optionId',
58
+ 'productId',
59
+ 'productName',
60
+ 'lastUpdatedFrom',
61
+ 'skipAccommodation',
62
+ ];
63
+
64
+ const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
65
+ const value = R.path([field], payload);
66
+ if (Array.isArray(value)) return value.length > 0;
67
+ if (typeof value === 'string') return Boolean(value.trim());
68
+ return value !== undefined && value !== null && value !== false;
69
+ });
70
+
55
71
  const productCount = value => R.pathOr([], ['products'], value).length;
56
72
 
73
+ const optionCount = value => R.pathOr([], ['products'], value).reduce(
74
+ (count, product) => count + R.pathOr([], ['options'], product).length,
75
+ 0,
76
+ );
77
+
57
78
  const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.products);
58
79
 
59
80
  const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
@@ -76,7 +97,11 @@ const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId
76
97
  };
77
98
  app.events.emit(productSearchCacheDecisionEvent, payload);
78
99
  if (legacyProductSearchCacheEvents[action]) {
79
- app.events.emit(legacyProductSearchCacheEvents[action], payload);
100
+ app.events.emit(legacyProductSearchCacheEvents[action], {
101
+ ...payload,
102
+ userId,
103
+ hint,
104
+ });
80
105
  }
81
106
  };
82
107
 
@@ -287,7 +312,11 @@ const $bookingsProductSearch = plugins => async ({
287
312
 
288
313
  // Payload for the plugin function (func) - pass forceRefresh so plugins can trigger background cache rebuilds
289
314
  const payloadForPlugin = { ...R.omit(['forceRefresh'], originalRequestBody), forceRefresh };
290
- const payloadForBackgroundJob = R.omit(['credentials'], payloadForPlugin);
315
+ const isScopedSearch = hasProductSearchSelector(originalRequestBody);
316
+ const payloadForBackgroundJob = R.omit(
317
+ ['credentials', ...productSearchSelectorFields],
318
+ payloadForPlugin,
319
+ );
291
320
 
292
321
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
293
322
  assert(userId, 'userId is required');
@@ -355,6 +384,26 @@ const $bookingsProductSearch = plugins => async ({
355
384
 
356
385
  // Helper function to call the plugin, save cache, and return results
357
386
  const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
387
+ if (isScopedSearch) {
388
+ const pluginStartedAt = Date.now();
389
+ const pluginResults = await func({
390
+ axios,
391
+ token,
392
+ payload: payloadForPlugin,
393
+ typeDefsAndQueries,
394
+ requestId,
395
+ userId,
396
+ hint,
397
+ });
398
+ emitDecision('scoped_result_not_cached', {
399
+ reason,
400
+ pluginElapsedMs: Date.now() - pluginStartedAt,
401
+ returnedProductCount: productCount(pluginResults),
402
+ returnedOptionCount: optionCount(pluginResults),
403
+ });
404
+ return pluginResults || { products: [] };
405
+ }
406
+
358
407
  const lockOwnerToken = crypto.randomBytes(16).toString('hex');
359
408
  const lockStartedAt = Date.now();
360
409
  const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.118",
3
+ "version": "1.0.120",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {