ti2 1.0.123 → 1.0.125

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 CHANGED
@@ -73,11 +73,12 @@ calling the integration; `cacheFound` distinguishes a cache miss from a cached
73
73
  empty catalog.
74
74
 
75
75
  A forced request is a full-catalog refresh by default. `fullSyncTrigger` may be
76
- `scheduled`, `manual`, or `organic`; scheduled and manual refreshes reject
77
- `searchInput`, `optionId`, `productId`, `productName`, or `lastUpdatedFrom`
78
- selectors. An empty `searchInput` or `searchInput: "*"` represents the full
79
- catalog. Scheduler-owned requests may also carry `fullSyncStartedAt`,
80
- `fullSyncAdmissionToken`, and an `admissionOverrideReason`.
76
+ `scheduled`, `manual`, or `organic`; an omitted trigger on a forced request is
77
+ treated as manual. Manual refreshes require a non-blank `admissionOverrideReason`.
78
+ Scheduled and manual refreshes reject `searchInput`, `optionId`, `productId`,
79
+ `productName`, or `lastUpdatedFrom` selectors. An empty `searchInput` or
80
+ `searchInput: "*"` represents the full catalog. Scheduler-owned requests may
81
+ also carry `fullSyncStartedAt` and `fullSyncAdmissionToken`.
81
82
 
82
83
  Unscoped forced responses include `catalogRefreshOutcome`, `cacheUpdated`,
83
84
  `cachePreserved`, and `cachedProductCount`. Consumers that update a downstream
@@ -93,6 +94,22 @@ The product cache refresh interval defaults to seven days when no TTR is set.
93
94
  Any explicit `ttlForProducts` or plugin `cacheSettings.bookingsProductSearch.ttr`
94
95
  value is honored, including `86400` for a one-day interval.
95
96
 
97
+ ### Optional catalog lifecycle service
98
+
99
+ Ti2 synchronously asks Filematch to update catalog lifecycle state when an
100
+ integration is removed or reactivated. The call reuses
101
+ `ti2_events2url_eventsURL` and `ti2_events2url_authorization`; Filematch then
102
+ forwards the request to Pyfilematch. Without the events URL, Ti2 cleans its own
103
+ credentials and schedules without making an external catalog request.
104
+ `INTEGRATION_LIFECYCLE_TIMEOUT_MS` controls the request timeout and defaults to
105
+ 30 seconds. A save left in `provisioning` blocks deletion until
106
+ it is older than `INTEGRATION_PROVISIONING_TIMEOUT_MS` (five minutes by
107
+ default); deletion then advances the generation before removing local and
108
+ catalog state. Catalog lifecycle consumers must accept newer generations so a
109
+ failed activation followed by deletion can move from generation N to N+1. The
110
+ timeout is a provisioning lease: saves refresh it before catalog activation and
111
+ must still own their generation when marking activation complete.
112
+
96
113
  ## Contributing
97
114
 
98
115
  Contributions are welcome and ecouraged.
package/api.yml CHANGED
@@ -101,6 +101,53 @@ components:
101
101
  properties:
102
102
  value:
103
103
  type: string
104
+ IntegrationCleanup:
105
+ type: object
106
+ required:
107
+ - userId
108
+ - integrationId
109
+ - hint
110
+ - generation
111
+ - requestId
112
+ - status
113
+ properties:
114
+ userId:
115
+ type: string
116
+ integrationId:
117
+ type: string
118
+ hint:
119
+ type: string
120
+ generation:
121
+ type: integer
122
+ minimum: 1
123
+ requestId:
124
+ type: string
125
+ requestedAt:
126
+ type: string
127
+ format: date-time
128
+ retryCount:
129
+ type: integer
130
+ minimum: 0
131
+ status:
132
+ type: string
133
+ enum:
134
+ - deleting
135
+ - failed
136
+ - external_cleanup
137
+ - complete
138
+ artifacts:
139
+ type: object
140
+ additionalProperties: true
141
+ IntegrationCleanupResponse:
142
+ type: object
143
+ required:
144
+ - message
145
+ - cleanup
146
+ properties:
147
+ message:
148
+ type: string
149
+ cleanup:
150
+ $ref: '#/components/schemas/IntegrationCleanup'
104
151
  CreateAppTokenResponse:
105
152
  type: object
106
153
  required:
@@ -186,6 +233,9 @@ components:
186
233
  type: string
187
234
  description: An identifier for this token, it should NOT be the token itself but a short safe version of it
188
235
  example: 'expediaTestLocation-23'
236
+ deferCompletion:
237
+ type: boolean
238
+ description: Keep re-creation blocked until the caller finalizes external cleanup
189
239
  UserAppValidate:
190
240
  description: A user + App configuration to validate
191
241
  type: object
@@ -254,6 +304,13 @@ components:
254
304
  type: string
255
305
  updatedAt:
256
306
  type: string
307
+ cleanupStatus:
308
+ type: string
309
+ description: Present when removal is incomplete and may be retried.
310
+ enum:
311
+ - deleting
312
+ - external_cleanup
313
+ - failed
257
314
  appRequest:
258
315
  type: object
259
316
  properties:
@@ -455,7 +512,7 @@ components:
455
512
  forceRefresh:
456
513
  type: boolean
457
514
  default: false
458
- description: Call the plugin directly. Full-catalog refreshes require the app's weeklyProductCatalogSync capability and default to a scheduled trigger when fullSyncTrigger is omitted.
515
+ description: Call the plugin directly. Full-catalog refreshes require the app's weeklyProductCatalogSync capability and default to a manual trigger when fullSyncTrigger is omitted. Manual refreshes require admissionOverrideReason.
459
516
  cacheOnly:
460
517
  type: boolean
461
518
  default: false
@@ -466,14 +523,14 @@ components:
466
523
  description: Execute the request through the Ti2 worker.
467
524
  fullSyncTrigger:
468
525
  type: string
469
- description: Catalog refresh owner. Scheduled and manual triggers reject selector fields.
526
+ description: Catalog refresh owner. Forced requests default to manual when omitted. Scheduled and manual triggers reject selector fields.
470
527
  enum:
471
528
  - organic
472
529
  - scheduled
473
530
  - manual
474
531
  admissionOverrideReason:
475
532
  type: string
476
- description: Audit reason for an admitted manual refresh override.
533
+ description: Required non-blank audit reason for a manual refresh override.
477
534
  fullSyncStartedAt:
478
535
  type: number
479
536
  description: Start time supplied by the full-sync admission owner.
@@ -982,7 +1039,7 @@ paths:
982
1039
  content:
983
1040
  application/json:
984
1041
  schema:
985
- $ref: '#/components/schemas/Message'
1042
+ $ref: '#/components/schemas/IntegrationCleanupResponse'
986
1043
  put:
987
1044
  tags:
988
1045
  - app
@@ -1020,6 +1077,56 @@ paths:
1020
1077
  application/json:
1021
1078
  schema:
1022
1079
  $ref: '#/components/schemas/Message'
1080
+ /{app}/{userId}/cleanup/finalize:
1081
+ post:
1082
+ tags:
1083
+ - app
1084
+ - user
1085
+ summary: Finalize an integration cleanup after external artifacts are removed
1086
+ security:
1087
+ - bearerAuth: ['admin', 'app', 'user']
1088
+ operationId: finalizeIntegrationCleanup
1089
+ parameters:
1090
+ - name: app
1091
+ in: path
1092
+ required: true
1093
+ schema:
1094
+ type: string
1095
+ - name: userId
1096
+ in: path
1097
+ required: true
1098
+ schema:
1099
+ type: string
1100
+ requestBody:
1101
+ required: true
1102
+ content:
1103
+ application/json:
1104
+ schema:
1105
+ type: object
1106
+ required:
1107
+ - tokenHint
1108
+ - generation
1109
+ - requestId
1110
+ properties:
1111
+ tokenHint:
1112
+ type: string
1113
+ generation:
1114
+ type: integer
1115
+ minimum: 1
1116
+ requestId:
1117
+ type: string
1118
+ minLength: 1
1119
+ maxLength: 64
1120
+ artifacts:
1121
+ type: object
1122
+ additionalProperties: true
1123
+ responses:
1124
+ 200:
1125
+ description: success
1126
+ content:
1127
+ application/json:
1128
+ schema:
1129
+ $ref: '#/components/schemas/IntegrationCleanupResponse'
1023
1130
  /{app}/{userId}/{hint}/token:
1024
1131
  get:
1025
1132
  tags:
@@ -38,6 +38,7 @@ const appCheck = async req => {
38
38
  return 'Invalid api key';
39
39
  }
40
40
  req.appRecord = appRecord.dataValues;
41
+ req.auth = { subject: `app:${req.pathParams.app}` };
41
42
  return undefined;
42
43
  };
43
44
 
@@ -53,6 +54,7 @@ const app = async (req, res, next) => {
53
54
  const adminCheck = async req => {
54
55
  const token = getToken({ req });
55
56
  if (token !== adminKey) return 'Invalid admin key';
57
+ req.auth = { subject: 'admin' };
56
58
  return undefined;
57
59
  };
58
60
 
@@ -78,6 +80,7 @@ const userCheck = async req => {
78
80
  if (req.params.userId && userId !== req.params.userId) {
79
81
  return 'Non matching user id';
80
82
  }
83
+ req.auth = { subject: `user:${userId}` };
81
84
  return undefined;
82
85
  };
83
86
 
@@ -29,6 +29,7 @@ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
29
29
  * **Condition 3: `forceRefresh` is true**:
30
30
  * This is the catalog refresh owner. The system fetches fresh data from the plugin.
31
31
  * Ti2 rejects the request unless the host declared `weeklyProductCatalogSync: true` for the plugin. Manual audit reasons may override timing admission, but not catalog-completeness eligibility.
32
+ * An omitted `fullSyncTrigger` is treated as `manual`, and manual refreshes require a non-blank `admissionOverrideReason`. Weekly scheduler requests must explicitly send `fullSyncTrigger: scheduled`.
32
33
  * Unless `fullSyncTrigger: organic` is explicit, a force refresh is a scheduled or manual full-catalog request and rejects `searchInput`, `optionId`, `productId`, `productName`, and `lastUpdatedFrom` selectors. `searchInput: "*"` is unscoped.
33
34
  * The `fetchFromPluginAndCache` helper function is invoked. This function:
34
35
  * Sets the `pluginExecutionLockKey` before calling the plugin to prevent other concurrent direct calls.
@@ -1,9 +1,11 @@
1
1
  /* globals beforeAll describe it expect */
2
2
  const chance = require('chance').Chance();
3
+ const crypto = require('crypto');
3
4
  const jwt = require('jwt-promise');
4
5
  const R = require('ramda');
5
6
 
6
7
  const slugify = require('../../test/slugify');
8
+ const { queue } = require('../../worker/queue');
7
9
  let appController = require('../app');
8
10
 
9
11
  const { env: { adminKey, jwtSecret } } = process;
@@ -13,6 +15,7 @@ describe('app', () => {
13
15
  const appName = slugify(
14
16
  chance.company(),
15
17
  );
18
+ const otherAppName = slugify(chance.company());
16
19
  const newApp = {
17
20
  name: appName,
18
21
  packageName: `ti2-${appName}`,
@@ -21,6 +24,8 @@ describe('app', () => {
21
24
  let doApiPost;
22
25
  let doApiGet;
23
26
  let doApiPut;
27
+ let db;
28
+ let plugins;
24
29
  let appKey;
25
30
  const userId = chance.guid();
26
31
  const apiKey = chance.guid();
@@ -34,8 +39,10 @@ describe('app', () => {
34
39
  };
35
40
  let encodedKey;
36
41
  beforeAll(async () => {
37
- ({ doApiPost, doApiGet, doApiPut } = await testUtils({
38
- plugins: [appName],
42
+ ({
43
+ doApiPost, doApiGet, doApiPut, plugins, sqldb: db,
44
+ } = await testUtils({
45
+ plugins: [appName, otherAppName],
39
46
  }));
40
47
  appController = appController([appName]);
41
48
  });
@@ -69,6 +76,12 @@ describe('app', () => {
69
76
  },
70
77
  });
71
78
  expect(parseInt(value, 10)).toBeGreaterThan(0);
79
+ expect(await db.models.CronJobs.count({
80
+ where: { userId, hint: apiKey.split('-')[0], pluginName: appName },
81
+ })).toBeGreaterThan(0);
82
+ expect(await db.models.CronJobs.count({
83
+ where: { userId, hint: apiKey.split('-')[0], pluginName: otherAppName },
84
+ })).toBe(0);
72
85
  });
73
86
  it('should be able to test a user token for the app', async () => {
74
87
  const { valid } = await doApiPost({
@@ -96,7 +109,39 @@ describe('app', () => {
96
109
  cron: '0 9 * * *',
97
110
  });
98
111
  });
99
- it.todo('if a new version of the plugin has a different set of scheduled tasks, they should be re-synced');
112
+ it('replaces the repeatable definition when a cron pattern changes', async () => {
113
+ const plugin = plugins.find(({ name }) => name === appName);
114
+ plugin.jobs[0].cron = '0 10 * * *';
115
+
116
+ await doApiPost({
117
+ url: `/${appName}/${userId}`,
118
+ token: appKey,
119
+ payload: {
120
+ tokenHint: apiKey.split('-')[0],
121
+ token,
122
+ },
123
+ });
124
+
125
+ const jobs = R.path(
126
+ ['jobs'],
127
+ await appController.getAppScheduledJobs({ integrationId: appName, userId }),
128
+ );
129
+ expect(R.head(R.project(['pluginJobId', 'cron'], jobs))).toEqual({
130
+ pluginJobId: 'dailyReport',
131
+ cron: '0 10 * * *',
132
+ });
133
+ const repeatId = crypto.createHash('sha256').update(JSON.stringify({
134
+ pluginName: appName,
135
+ pluginJobId: 'dailyReport',
136
+ userId,
137
+ hint: apiKey.split('-')[0],
138
+ })).digest('hex');
139
+ const repeatDefinitions = (await queue.getRepeatableJobs())
140
+ .filter(({ id }) => id === repeatId);
141
+ expect(repeatDefinitions).toEqual([
142
+ expect.objectContaining({ cron: '0 10 * * *' }),
143
+ ]);
144
+ });
100
145
  let jobId;
101
146
  it('should be able to run a job', async () => {
102
147
  let status;
@@ -134,7 +134,7 @@ describe('user: bookings controller - searchProducts', () => {
134
134
  const response = await doApiPost({
135
135
  url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
136
136
  token: userToken,
137
- payload: { forceRefresh: true },
137
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
138
138
  expectStatusCode: 500,
139
139
  });
140
140
 
@@ -146,6 +146,7 @@ describe('user: bookings controller - searchProducts', () => {
146
146
  // 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
147
147
  const payload = {
148
148
  forceRefresh: true,
149
+ fullSyncTrigger: 'scheduled',
149
150
  };
150
151
  const { products } = await doApiPost({
151
152
  url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
@@ -253,7 +254,10 @@ describe('user: bookings controller - searchProducts', () => {
253
254
  await doApiPost({
254
255
  url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
255
256
  token: userToken,
256
- payload: { forceRefresh: true },
257
+ payload: {
258
+ forceRefresh: true,
259
+ admissionOverrideReason: 'legacy manual refresh',
260
+ },
257
261
  });
258
262
  const cacheSavePayload = emitSpy.mock.calls
259
263
  .filter(call => call[0] === 'bookingsProductSearch:cache:save')
@@ -265,6 +269,8 @@ describe('user: bookings controller - searchProducts', () => {
265
269
  expect(cacheSavePayload.hint).toBe(testHint);
266
270
  expect(cacheSavePayload.userIdHash).toBeTruthy();
267
271
  expect(cacheSavePayload.hintHash).toBeTruthy();
272
+ expect(cacheSavePayload.fullSyncTrigger).toBe('manual');
273
+ expect(cacheSavePayload.admissionOverrideReason).toBe('legacy manual refresh');
268
274
  emitSpy.mockRestore();
269
275
  });
270
276
 
@@ -355,7 +361,7 @@ describe('user: bookings controller - searchProducts', () => {
355
361
  await doApiPost({
356
362
  url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
357
363
  token: userToken,
358
- payload: { forceRefresh: true },
364
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
359
365
  }).then(({ products: p }) => {
360
366
  products = p; // Assign to the outer scoped 'products'
361
367
  });
@@ -415,7 +421,11 @@ describe('user: bookings controller - searchProducts', () => {
415
421
  await doApiPost({
416
422
  url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
417
423
  token: userToken,
418
- payload: { forceRefresh: true, omitServiceCodes: [] },
424
+ payload: {
425
+ forceRefresh: true,
426
+ fullSyncTrigger: 'scheduled',
427
+ omitServiceCodes: [],
428
+ },
419
429
  });
420
430
 
421
431
  expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
@@ -642,7 +652,7 @@ describe('user: bookings controller - searchProducts', () => {
642
652
  const result = await doApiPost({
643
653
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
644
654
  token: userToken,
645
- payload: { forceRefresh: true },
655
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
646
656
  });
647
657
 
648
658
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
@@ -687,7 +697,7 @@ describe('user: bookings controller - searchProducts', () => {
687
697
  await doApiPost({
688
698
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
689
699
  token: userToken,
690
- payload: { forceRefresh: true },
700
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
691
701
  });
692
702
 
693
703
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
@@ -725,7 +735,7 @@ describe('user: bookings controller - searchProducts', () => {
725
735
  const result = await doApiPost({
726
736
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
727
737
  token: userToken,
728
- payload: { forceRefresh: true },
738
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
729
739
  });
730
740
 
731
741
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
@@ -764,15 +774,22 @@ describe('user: bookings controller - searchProducts', () => {
764
774
  },
765
775
  );
766
776
 
767
- it('rejects a forced selector when fullSyncTrigger is omitted', async () => {
777
+ it.each([
778
+ ['an omitted trigger', { forceRefresh: true }],
779
+ ['a blank reason', {
780
+ forceRefresh: true,
781
+ fullSyncTrigger: 'manual',
782
+ admissionOverrideReason: ' ',
783
+ }],
784
+ ])('rejects a manual catalog refresh with %s', async (_description, payload) => {
768
785
  const response = await doApiPost({
769
786
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
770
787
  token: userToken,
771
- payload: { forceRefresh: true, optionId: 'option-1' },
788
+ payload,
772
789
  expectStatusCode: 400,
773
790
  });
774
791
 
775
- expect(response.message).toContain('must not include product selectors');
792
+ expect(response.message).toContain('requires a non-blank admissionOverrideReason');
776
793
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
777
794
  });
778
795
 
@@ -847,7 +864,7 @@ describe('user: bookings controller - searchProducts', () => {
847
864
  const result = await doApiPost({
848
865
  url: `/products/${testAppName}/${testUserId}/${configuredHint}/search`,
849
866
  token: userToken,
850
- payload: { forceRefresh: true },
867
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
851
868
  });
852
869
 
853
870
  expect(result).toEqual(expect.objectContaining({
@@ -1130,7 +1147,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1130
1147
  const makeForceRequest = () => doApiPost({
1131
1148
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1132
1149
  token: userToken,
1133
- payload: { forceRefresh: true },
1150
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
1134
1151
  });
1135
1152
 
1136
1153
  const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
@@ -1185,7 +1202,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1185
1202
  const makeForceRequest = () => doApiPost({
1186
1203
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1187
1204
  token: userToken,
1188
- payload: { forceRefresh: true },
1205
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
1189
1206
  });
1190
1207
 
1191
1208
  const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
@@ -1236,7 +1253,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1236
1253
  const result = await doApiPost({
1237
1254
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1238
1255
  token: userToken,
1239
- payload: { forceRefresh: true },
1256
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
1240
1257
  });
1241
1258
 
1242
1259
  expect(result).toEqual(expect.objectContaining({
@@ -97,11 +97,25 @@ describe('user', () => {
97
97
  expect(template).toBeTruthy();
98
98
  expect(R.path(['apiKey', 'regExp', 'source'], template)).toBeTruthy();
99
99
  });
100
+ it('returns 404 when deleting a hint with no credential or lifecycle', async () => {
101
+ const unknownHint = chance.word({ length: 12 });
102
+ await doApiDelete({
103
+ url: `/${appName}/${userId}`,
104
+ token: userKey,
105
+ payload: { tokenHint: unknownHint },
106
+ expectStatusCode: 404,
107
+ });
108
+
109
+ expect(await db.models.IntegrationLifecycle.count({
110
+ where: { userId, integrationId: appName, hint: unknownHint },
111
+ })).toBe(0);
112
+ });
100
113
  it('should be able to delete a user/app key', async () => {
114
+ const deletedHint = apiKey.split('-')[0];
101
115
  await doApiDelete({
102
116
  url: `/${appName}/${userId}`,
103
117
  token: userKey,
104
- payload: { tokenHint: apiKey.split('-')[0] },
118
+ payload: { tokenHint: deletedHint },
105
119
  });
106
120
  const { userAppKeys } = await doApiGet({
107
121
  url: `/user/${userId}/apps`,
@@ -116,6 +130,14 @@ describe('user', () => {
116
130
  }),
117
131
  ]),
118
132
  );
133
+ const lifecycle = await db.models.IntegrationLifecycle.findOne({
134
+ where: {
135
+ userId,
136
+ integrationId: appName,
137
+ hint: deletedHint,
138
+ },
139
+ });
140
+ expect(lifecycle.requestedBy).toBe(`user:${userId}`);
119
141
  });
120
142
  it('should be able to create a user/app integration', async () => {
121
143
  // set up new token
@@ -145,6 +167,70 @@ describe('user', () => {
145
167
  ]),
146
168
  );
147
169
  });
170
+ it('reports deferred deletion as pending until tokenHint finalization', async () => {
171
+ const deferredHint = chance.word({ length: 12 });
172
+ await doApiPost({
173
+ url: `/${appName}/${userId}`,
174
+ token: userKey,
175
+ payload: { tokenHint: deferredHint, token },
176
+ });
177
+
178
+ const deletion = await doApiDelete({
179
+ url: `/${appName}/${userId}`,
180
+ token: userKey,
181
+ payload: { tokenHint: deferredHint, deferCompletion: true },
182
+ });
183
+ expect(deletion).toEqual(expect.objectContaining({
184
+ message: 'Integration cleanup is awaiting external finalization.',
185
+ cleanup: expect.objectContaining({ status: 'external_cleanup' }),
186
+ }));
187
+
188
+ const pendingList = await doApiGet({
189
+ url: `/user/${userId}/apps`,
190
+ token: userKey,
191
+ });
192
+ expect(pendingList.userAppKeys).toEqual(expect.arrayContaining([
193
+ expect.objectContaining({
194
+ integrationId: appName,
195
+ userId,
196
+ hint: deferredHint,
197
+ cleanupStatus: 'external_cleanup',
198
+ }),
199
+ ]));
200
+
201
+ await doApiPost({
202
+ url: `/${appName}/${userId}/cleanup/finalize`,
203
+ token: userKey,
204
+ payload: {
205
+ tokenHint: deferredHint,
206
+ generation: deletion.cleanup.generation,
207
+ },
208
+ expectStatusCode: 400,
209
+ });
210
+
211
+ const finalized = await doApiPost({
212
+ url: `/${appName}/${userId}/cleanup/finalize`,
213
+ token: userKey,
214
+ payload: {
215
+ tokenHint: deferredHint,
216
+ generation: deletion.cleanup.generation,
217
+ requestId: deletion.cleanup.requestId,
218
+ artifacts: { test: true },
219
+ },
220
+ });
221
+ expect(finalized.cleanup.status).toBe('complete');
222
+
223
+ const completedList = await doApiGet({
224
+ url: `/user/${userId}/apps`,
225
+ token: userKey,
226
+ });
227
+ expect(completedList.userAppKeys).toEqual(expect.not.arrayContaining([
228
+ expect.objectContaining({
229
+ integrationId: appName,
230
+ hint: deferredHint,
231
+ }),
232
+ ]));
233
+ });
148
234
  it('reports post-save cron setup failure while preserving the committed credential', async () => {
149
235
  const postCommitUserId = chance.guid();
150
236
  const postCommitUserKey = (await doApiPost({