@shipfox/api-integration-jira 12.2.0 → 12.4.0

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.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +8 -7
  2. package/.turbo/turbo-type$colon$emit.log +0 -1
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +5 -1
  5. package/dist/api/client.d.ts +11 -1
  6. package/dist/api/client.d.ts.map +1 -1
  7. package/dist/api/client.js +57 -12
  8. package/dist/api/client.js.map +1 -1
  9. package/dist/core/disconnect.d.ts +12 -0
  10. package/dist/core/disconnect.d.ts.map +1 -1
  11. package/dist/core/disconnect.js +45 -1
  12. package/dist/core/disconnect.js.map +1 -1
  13. package/dist/core/install.d.ts +1 -0
  14. package/dist/core/install.d.ts.map +1 -1
  15. package/dist/core/install.js +2 -1
  16. package/dist/core/install.js.map +1 -1
  17. package/dist/core/tokens.d.ts +2 -0
  18. package/dist/core/tokens.d.ts.map +1 -1
  19. package/dist/core/tokens.js +2 -2
  20. package/dist/core/tokens.js.map +1 -1
  21. package/dist/core/webhook-registration.d.ts +1 -0
  22. package/dist/core/webhook-registration.d.ts.map +1 -1
  23. package/dist/core/webhook-registration.js +3 -1
  24. package/dist/core/webhook-registration.js.map +1 -1
  25. package/dist/core/webhook-url.d.ts +3 -0
  26. package/dist/core/webhook-url.d.ts.map +1 -0
  27. package/dist/core/webhook-url.js +8 -0
  28. package/dist/core/webhook-url.js.map +1 -0
  29. package/dist/db/installations.d.ts +13 -0
  30. package/dist/db/installations.d.ts.map +1 -1
  31. package/dist/db/installations.js +18 -1
  32. package/dist/db/installations.js.map +1 -1
  33. package/dist/index.d.ts +17 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -7
  36. package/dist/index.js.map +1 -1
  37. package/dist/presentation/routes/webhooks.d.ts +2 -1
  38. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  39. package/dist/presentation/routes/webhooks.js +2 -1
  40. package/dist/presentation/routes/webhooks.js.map +1 -1
  41. package/dist/temporal/activities/index.d.ts +8 -1
  42. package/dist/temporal/activities/index.d.ts.map +1 -1
  43. package/dist/temporal/activities/index.js +8 -1
  44. package/dist/temporal/activities/index.js.map +1 -1
  45. package/dist/temporal/activities/renew-jira-webhooks.d.ts +26 -0
  46. package/dist/temporal/activities/renew-jira-webhooks.d.ts.map +1 -0
  47. package/dist/temporal/activities/renew-jira-webhooks.js +96 -0
  48. package/dist/temporal/activities/renew-jira-webhooks.js.map +1 -0
  49. package/dist/temporal/constants.d.ts +2 -0
  50. package/dist/temporal/constants.d.ts.map +1 -1
  51. package/dist/temporal/constants.js +2 -0
  52. package/dist/temporal/constants.js.map +1 -1
  53. package/dist/temporal/worker.d.ts +3 -0
  54. package/dist/temporal/worker.d.ts.map +1 -1
  55. package/dist/temporal/worker.js +5 -0
  56. package/dist/temporal/worker.js.map +1 -1
  57. package/dist/temporal/workflows/index.bundle.js +38 -2
  58. package/dist/temporal/workflows/index.d.ts +1 -0
  59. package/dist/temporal/workflows/index.d.ts.map +1 -1
  60. package/dist/temporal/workflows/index.js +1 -0
  61. package/dist/temporal/workflows/index.js.map +1 -1
  62. package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts +2 -0
  63. package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts.map +1 -0
  64. package/dist/temporal/workflows/renew-jira-webhooks-cron.js +18 -0
  65. package/dist/temporal/workflows/renew-jira-webhooks-cron.js.map +1 -0
  66. package/dist/tsconfig.test.tsbuildinfo +1 -1
  67. package/package.json +2 -2
  68. package/src/api/client.test.ts +83 -4
  69. package/src/api/client.ts +70 -8
  70. package/src/core/disconnect.test.ts +107 -1
  71. package/src/core/disconnect.ts +64 -1
  72. package/src/core/install.test.ts +23 -0
  73. package/src/core/install.ts +5 -2
  74. package/src/core/tokens-refresh.test.ts +39 -0
  75. package/src/core/tokens.ts +4 -2
  76. package/src/core/webhook-registration.test.ts +32 -0
  77. package/src/core/webhook-registration.ts +4 -1
  78. package/src/core/webhook-url.ts +9 -0
  79. package/src/db/installations.test.ts +82 -0
  80. package/src/db/installations.ts +54 -1
  81. package/src/index.test.ts +21 -1
  82. package/src/index.ts +21 -9
  83. package/src/presentation/routes/webhooks.ts +3 -1
  84. package/src/temporal/activities/index.ts +20 -3
  85. package/src/temporal/activities/renew-jira-webhooks.test.ts +215 -0
  86. package/src/temporal/activities/renew-jira-webhooks.ts +144 -0
  87. package/src/temporal/constants.ts +2 -0
  88. package/src/temporal/worker.ts +11 -0
  89. package/src/temporal/workflows/index.ts +1 -0
  90. package/src/temporal/workflows/renew-jira-webhooks-cron.ts +17 -0
  91. package/tsconfig.build.tsbuildinfo +1 -1
@@ -8,10 +8,12 @@ import {
8
8
  getJiraInstallationByConnectionId,
9
9
  getJiraInstallationByWebhookId,
10
10
  listJiraInstallationsDueForTokenRefresh,
11
+ listJiraInstallationsDueForWebhookRenewal,
11
12
  markJiraInstallationRevoked,
12
13
  markJiraInstallationTokenRefreshAttempt,
13
14
  updateJiraInstallationTokenExpiry,
14
15
  updateJiraInstallationWebhook,
16
+ updateJiraInstallationWebhookIfUnchanged,
15
17
  upsertJiraInstallation,
16
18
  withJiraWebhookRegistrationLock,
17
19
  } from './installations.js';
@@ -96,6 +98,53 @@ describe('jira installations', () => {
96
98
  expect(result).toMatchObject({webhookIds: [654], webhookExpiresAt});
97
99
  });
98
100
 
101
+ it('only updates webhook metadata when the refreshed ids are still current', async () => {
102
+ const currentWebhookExpiresAt = new Date('2026-08-05T00:00:00.000Z');
103
+ const input = createInstallationInput({
104
+ webhookIds: [123],
105
+ webhookExpiresAt: currentWebhookExpiresAt,
106
+ });
107
+ await upsertJiraInstallation(input);
108
+
109
+ await expect(
110
+ updateJiraInstallationWebhookIfUnchanged({
111
+ connectionId: input.connectionId,
112
+ expectedWebhookIds: [456],
113
+ expectedWebhookExpiresAt: currentWebhookExpiresAt,
114
+ webhookIds: [789],
115
+ webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
116
+ }),
117
+ ).resolves.toBeUndefined();
118
+ await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toMatchObject({
119
+ webhookIds: [123],
120
+ webhookExpiresAt: currentWebhookExpiresAt,
121
+ });
122
+
123
+ await expect(
124
+ updateJiraInstallationWebhookIfUnchanged({
125
+ connectionId: input.connectionId,
126
+ expectedWebhookIds: [123],
127
+ expectedWebhookExpiresAt: new Date('2026-08-04T00:00:00.000Z'),
128
+ webhookIds: [789],
129
+ webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
130
+ }),
131
+ ).resolves.toBeUndefined();
132
+ await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toMatchObject({
133
+ webhookIds: [123],
134
+ webhookExpiresAt: currentWebhookExpiresAt,
135
+ });
136
+
137
+ await expect(
138
+ updateJiraInstallationWebhookIfUnchanged({
139
+ connectionId: input.connectionId,
140
+ expectedWebhookIds: [123],
141
+ expectedWebhookExpiresAt: currentWebhookExpiresAt,
142
+ webhookIds: [789],
143
+ webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
144
+ }),
145
+ ).resolves.toMatchObject({webhookIds: [789]});
146
+ });
147
+
99
148
  it('refuses to repoint a connection to a different Jira site', async () => {
100
149
  const first = createInstallationInput();
101
150
  await upsertJiraInstallation(first);
@@ -186,6 +235,39 @@ describe('jira installations', () => {
186
235
  );
187
236
  });
188
237
 
238
+ it('lists only installed installations whose webhooks are nearing expiry', async () => {
239
+ const due = createInstallationInput({
240
+ webhookExpiresAt: new Date('2026-08-05T00:00:00.000Z'),
241
+ });
242
+ const fresh = createInstallationInput({
243
+ webhookExpiresAt: new Date('2026-08-20T00:00:00.000Z'),
244
+ });
245
+ const missingExpiry = createInstallationInput({webhookExpiresAt: null});
246
+ const revoked = createInstallationInput({
247
+ webhookExpiresAt: new Date('2026-08-05T00:00:00.000Z'),
248
+ status: 'revoked',
249
+ });
250
+ await upsertJiraInstallation(due);
251
+ await upsertJiraInstallation(fresh);
252
+ await upsertJiraInstallation(missingExpiry);
253
+ await upsertJiraInstallation(revoked);
254
+
255
+ const result = await listJiraInstallationsDueForWebhookRenewal({
256
+ before: new Date('2026-08-07T00:00:00.000Z'),
257
+ });
258
+
259
+ expect(result.map((installation) => installation.connectionId)).toContain(due.connectionId);
260
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
261
+ fresh.connectionId,
262
+ );
263
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
264
+ missingExpiry.connectionId,
265
+ );
266
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
267
+ revoked.connectionId,
268
+ );
269
+ });
270
+
189
271
  it('orders due installations by their latest refresh attempt', async () => {
190
272
  const refreshTokenLastUsedAt = new Date('1970-01-01T00:00:00.000Z');
191
273
  const attemptBase = Date.now();
@@ -1,6 +1,6 @@
1
1
  import {isUniqueViolation} from '@shipfox/node-drizzle';
2
2
  import {pgClient, withPostgresSession} from '@shipfox/node-postgres';
3
- import {and, asc, eq, lte, sql} from 'drizzle-orm';
3
+ import {and, asc, eq, isNotNull, isNull, lte, sql} from 'drizzle-orm';
4
4
  import {
5
5
  JiraConnectionAlreadyLinkedError,
6
6
  JiraInstallationAlreadyLinkedError,
@@ -83,6 +83,12 @@ export interface UpdateJiraInstallationWebhookParams {
83
83
  webhookExpiresAt: Date | null;
84
84
  }
85
85
 
86
+ export interface UpdateJiraInstallationWebhookIfUnchangedParams
87
+ extends UpdateJiraInstallationWebhookParams {
88
+ expectedWebhookIds: number[];
89
+ expectedWebhookExpiresAt: Date | null;
90
+ }
91
+
86
92
  type JiraDb = ReturnType<typeof db>;
87
93
  type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
88
94
 
@@ -211,6 +217,26 @@ export async function listJiraInstallationsDueForTokenRefresh(
211
217
  return rows.map(toJiraInstallation);
212
218
  }
213
219
 
220
+ export async function listJiraInstallationsDueForWebhookRenewal(
221
+ params: {before: Date; limit?: number | undefined},
222
+ options: {tx?: unknown} = {},
223
+ ): Promise<JiraInstallation[]> {
224
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
225
+ const rows = await executor
226
+ .select()
227
+ .from(jiraInstallations)
228
+ .where(
229
+ and(
230
+ eq(jiraInstallations.status, 'installed'),
231
+ isNotNull(jiraInstallations.webhookExpiresAt),
232
+ lte(jiraInstallations.webhookExpiresAt, params.before),
233
+ ),
234
+ )
235
+ .orderBy(asc(jiraInstallations.webhookExpiresAt))
236
+ .limit(params.limit ?? 100);
237
+ return rows.map(toJiraInstallation);
238
+ }
239
+
214
240
  export async function updateJiraInstallationWebhook(
215
241
  params: UpdateJiraInstallationWebhookParams,
216
242
  options: {tx?: unknown} = {},
@@ -228,6 +254,33 @@ export async function updateJiraInstallationWebhook(
228
254
  return row ? toJiraInstallation(row) : undefined;
229
255
  }
230
256
 
257
+ export async function updateJiraInstallationWebhookIfUnchanged(
258
+ params: UpdateJiraInstallationWebhookIfUnchangedParams,
259
+ options: {tx?: unknown} = {},
260
+ ): Promise<JiraInstallation | undefined> {
261
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
262
+ const expectedWebhookIds = JSON.stringify([...new Set(params.expectedWebhookIds)]);
263
+ const [row] = await executor
264
+ .update(jiraInstallations)
265
+ .set({
266
+ webhookIds: params.webhookIds,
267
+ webhookExpiresAt: params.webhookExpiresAt,
268
+ updatedAt: new Date(),
269
+ })
270
+ .where(
271
+ and(
272
+ eq(jiraInstallations.connectionId, params.connectionId),
273
+ sql`${jiraInstallations.webhookIds} @> ${expectedWebhookIds}::jsonb`,
274
+ sql`${jiraInstallations.webhookIds} <@ ${expectedWebhookIds}::jsonb`,
275
+ params.expectedWebhookExpiresAt === null
276
+ ? isNull(jiraInstallations.webhookExpiresAt)
277
+ : eq(jiraInstallations.webhookExpiresAt, params.expectedWebhookExpiresAt),
278
+ ),
279
+ )
280
+ .returning();
281
+ return row ? toJiraInstallation(row) : undefined;
282
+ }
283
+
231
284
  export async function deleteJiraInstallationByConnectionId(
232
285
  connectionId: string,
233
286
  options: {tx?: unknown} = {},
package/src/index.test.ts CHANGED
@@ -34,11 +34,17 @@ describe('createJiraIntegrationProvider', () => {
34
34
 
35
35
  it('exposes explicit connection cleanup without requiring routes', () => {
36
36
  const deleteConnectionRecords = vi.fn(() => Promise.resolve());
37
+ const deleteConnectionRemoteResources = vi.fn(() => Promise.resolve(undefined));
37
38
  const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
38
39
  const provider = createJiraIntegrationProvider({
39
- cleanup: {deleteConnectionRecords, deleteConnectionSecrets},
40
+ cleanup: {
41
+ deleteConnectionRemoteResources,
42
+ deleteConnectionRecords,
43
+ deleteConnectionSecrets,
44
+ },
40
45
  });
41
46
 
47
+ expect(provider.deleteConnectionRemoteResources).toBe(deleteConnectionRemoteResources);
42
48
  expect(provider.deleteConnectionRecords).toBe(deleteConnectionRecords);
43
49
  expect(provider.deleteConnectionSecrets).toBe(deleteConnectionSecrets);
44
50
  });
@@ -48,6 +54,12 @@ describe('createJiraMaintenanceWorker', () => {
48
54
  it('describes the Jira proactive token refresh worker', () => {
49
55
  const worker = createJiraMaintenanceWorker({
50
56
  tokenStore: {getAccessToken: vi.fn()},
57
+ jira: {
58
+ refreshDynamicWebhooks: vi.fn(),
59
+ registerDynamicWebhook: vi.fn(),
60
+ deleteDynamicWebhook: vi.fn(),
61
+ },
62
+ webhookUrlForConnection: (connectionId) => `https://example.test/${connectionId}`,
51
63
  resolveConnection: vi.fn(),
52
64
  });
53
65
 
@@ -56,12 +68,20 @@ describe('createJiraMaintenanceWorker', () => {
56
68
  expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
57
69
  'refreshJiraTokensActivity',
58
70
  );
71
+ expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
72
+ 'renewJiraWebhooksActivity',
73
+ );
59
74
  expect(worker.workflows).toEqual([
60
75
  {
61
76
  name: 'refreshJiraTokensCron',
62
77
  id: 'jira-refresh-tokens',
63
78
  cronSchedule: '0 */6 * * *',
64
79
  },
80
+ {
81
+ name: 'renewJiraWebhooksCron',
82
+ id: 'jira-renew-webhooks',
83
+ cronSchedule: '0 */6 * * *',
84
+ },
65
85
  ]);
66
86
  });
67
87
  });
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
10
10
  import type {JiraTokenStore} from '#core/tokens.js';
11
11
  import {createJiraWebhookProcessor} from '#core/webhook-processor.js';
12
12
  import {registerJiraWebhook} from '#core/webhook-registration.js';
13
+ import {jiraWebhookUrl} from '#core/webhook-url.js';
13
14
  import {closeDb, db} from '#db/db.js';
14
15
  import {getJiraInstallationByConnectionId} from '#db/installations.js';
15
16
  import {migrationsPath} from '#db/migrations.js';
@@ -20,11 +21,8 @@ import {
20
21
  import {
21
22
  type CreateJiraWebhookRoutesOptions,
22
23
  createJiraWebhookRoutes,
23
- JIRA_WEBHOOK_ROUTE_PREFIX,
24
24
  } from '#presentation/routes/webhooks.js';
25
25
 
26
- const TRAILING_SLASHES_RE = /\/+$/;
27
-
28
26
  export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
29
27
  export type {
30
28
  JiraAccessibleResource,
@@ -60,8 +58,15 @@ export type {
60
58
  JiraToolCallResult,
61
59
  } from '#core/agent-tools-provider.js';
62
60
  export {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
63
- export type {DisconnectJiraInstallationParams} from '#core/disconnect.js';
64
- export {disconnectJiraInstallation} from '#core/disconnect.js';
61
+ export type {
62
+ DeregisterJiraWebhooksParams,
63
+ DisconnectJiraInstallationParams,
64
+ } from '#core/disconnect.js';
65
+ export {
66
+ deregisterJiraWebhooks,
67
+ disconnectJiraInstallation,
68
+ prepareJiraWebhookDeregistration,
69
+ } from '#core/disconnect.js';
65
70
  export {
66
71
  JiraAccessTokenMissingError,
67
72
  JiraAuthorizationScopeMismatchError,
@@ -108,10 +113,12 @@ export type {
108
113
  export {createJiraWebhookProcessor} from '#core/webhook-processor.js';
109
114
  export type {RegisterJiraWebhookParams} from '#core/webhook-registration.js';
110
115
  export {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from '#core/webhook-registration.js';
116
+ export {JIRA_WEBHOOK_ROUTE_PREFIX, jiraWebhookUrl} from '#core/webhook-url.js';
111
117
  export type {
112
118
  JiraInstallation,
113
119
  JiraInstallationLock,
114
120
  JiraInstallationStatus,
121
+ UpdateJiraInstallationWebhookIfUnchangedParams,
115
122
  UpdateJiraInstallationWebhookParams,
116
123
  UpsertJiraInstallationParams,
117
124
  } from '#db/installations.js';
@@ -121,9 +128,11 @@ export {
121
128
  getJiraInstallationByConnectionId,
122
129
  getJiraInstallationByWebhookId,
123
130
  listJiraInstallationsDueForTokenRefresh,
131
+ listJiraInstallationsDueForWebhookRenewal,
124
132
  markJiraInstallationRevoked,
125
133
  updateJiraInstallationTokenExpiry,
126
134
  updateJiraInstallationWebhook,
135
+ updateJiraInstallationWebhookIfUnchanged,
127
136
  upsertJiraInstallation,
128
137
  withJiraRefreshLock,
129
138
  withJiraRefreshLockAndWait,
@@ -145,6 +154,13 @@ export interface CreateJiraIntegrationProviderOptions {
145
154
  getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
146
155
  cleanup?:
147
156
  | {
157
+ deleteConnectionRemoteResources?: (connection: {
158
+ id: string;
159
+ }) => Promise<(() => Promise<void>) | undefined>;
160
+ withConnectionDeletionLock?: (
161
+ connection: {id: string},
162
+ fn: () => Promise<void>,
163
+ ) => Promise<void>;
148
164
  deleteConnectionRecords?: (
149
165
  connection: {id: string},
150
166
  options: {tx: unknown},
@@ -228,10 +244,6 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
228
244
  };
229
245
  }
230
246
 
231
- function jiraWebhookUrl(connectionId: string): string {
232
- return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
233
- }
234
-
235
247
  function toJiraWebhookOptions(
236
248
  routes: CreateJiraIntegrationProviderOptions['routes'],
237
249
  ): CreateJiraWebhookRoutesOptions | undefined {
@@ -14,9 +14,11 @@ import {
14
14
  createJiraWebhookProcessor,
15
15
  type JiraWebhookProcessor,
16
16
  } from '#core/webhook-processor.js';
17
+ import {JIRA_WEBHOOK_ROUTE_PREFIX} from '#core/webhook-url.js';
17
18
 
18
19
  const jiraWebhookParamsSchema = z.object({connectionId: z.string().uuid()});
19
- export const JIRA_WEBHOOK_ROUTE_PREFIX = '/webhooks/integrations/jira';
20
+
21
+ export {JIRA_WEBHOOK_ROUTE_PREFIX};
20
22
 
21
23
  export interface CreateJiraWebhookRoutesOptions
22
24
  extends Omit<CreateJiraWebhookProcessorOptions, 'getJiraInstallationByConnectionId'> {
@@ -1,5 +1,22 @@
1
- export {
2
- type CreateJiraMaintenanceActivitiesOptions,
3
- createJiraMaintenanceActivities,
1
+ import {
2
+ type CreateJiraMaintenanceActivitiesOptions as CreateJiraTokenMaintenanceActivitiesOptions,
3
+ createJiraMaintenanceActivities as createJiraTokenMaintenanceActivities,
4
4
  type JiraTokenRefreshActivityResult,
5
5
  } from './refresh-jira-tokens.js';
6
+ import {
7
+ type CreateJiraWebhookRenewalActivitiesOptions,
8
+ createJiraWebhookRenewalActivities,
9
+ type JiraWebhookRenewalActivityResult,
10
+ } from './renew-jira-webhooks.js';
11
+
12
+ export type CreateJiraMaintenanceActivitiesOptions = CreateJiraTokenMaintenanceActivitiesOptions &
13
+ CreateJiraWebhookRenewalActivitiesOptions;
14
+
15
+ export function createJiraMaintenanceActivities(options: CreateJiraMaintenanceActivitiesOptions) {
16
+ return {
17
+ ...createJiraTokenMaintenanceActivities(options),
18
+ ...createJiraWebhookRenewalActivities(options),
19
+ };
20
+ }
21
+
22
+ export type {JiraTokenRefreshActivityResult, JiraWebhookRenewalActivityResult};
@@ -0,0 +1,215 @@
1
+ import type {JiraInstallation} from '#db/installations.js';
2
+ import {JIRA_WEBHOOK_RENEWAL_THRESHOLD_MS} from '#temporal/constants.js';
3
+ import {createJiraWebhookRenewalActivities} from './renew-jira-webhooks.js';
4
+
5
+ function installation(
6
+ connectionId: string,
7
+ overrides: Partial<JiraInstallation> = {},
8
+ ): JiraInstallation {
9
+ const now = new Date('2026-08-01T00:00:00.000Z');
10
+ return {
11
+ id: crypto.randomUUID(),
12
+ connectionId,
13
+ cloudId: crypto.randomUUID(),
14
+ siteUrl: 'https://acme.atlassian.net',
15
+ siteName: 'Acme',
16
+ authorizingAccountId: crypto.randomUUID(),
17
+ scopes: ['manage:jira-webhook'],
18
+ webhookIds: [123],
19
+ webhookExpiresAt: new Date('2026-08-05T00:00:00.000Z'),
20
+ status: 'installed',
21
+ tokenExpiresAt: now,
22
+ refreshTokenLastUsedAt: now,
23
+ refreshTokenLastAttemptedAt: now,
24
+ createdAt: now,
25
+ updatedAt: now,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe('Jira webhook renewal activity', () => {
31
+ it('refreshes due active connections and skips outside-threshold or inactive connections', async () => {
32
+ const now = new Date('2026-08-01T00:00:00.000Z');
33
+ const dueConnectionId = crypto.randomUUID();
34
+ const outsideConnectionId = crypto.randomUUID();
35
+ const inactiveConnectionId = crypto.randomUUID();
36
+ const due = installation(dueConnectionId);
37
+ const outside = installation(outsideConnectionId, {
38
+ webhookExpiresAt: new Date(now.getTime() + JIRA_WEBHOOK_RENEWAL_THRESHOLD_MS + 1),
39
+ });
40
+ const inactive = installation(inactiveConnectionId);
41
+ const expirationDate = new Date('2026-08-31T00:00:00.000Z');
42
+ const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
43
+ const refreshDynamicWebhooks = vi.fn().mockResolvedValue(expirationDate);
44
+ const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
45
+ const resolveConnection = vi
46
+ .fn()
47
+ .mockImplementation(async (connectionId: string) =>
48
+ connectionId === inactiveConnectionId
49
+ ? {lifecycleStatus: 'error'}
50
+ : {lifecycleStatus: 'active'},
51
+ );
52
+ const listInstallations = vi.fn().mockResolvedValue([due, outside, inactive]);
53
+ const activities = createJiraWebhookRenewalActivities({
54
+ tokenStore: {getAccessToken},
55
+ jira: {
56
+ refreshDynamicWebhooks,
57
+ registerDynamicWebhook: vi.fn(),
58
+ deleteDynamicWebhook: vi.fn(),
59
+ },
60
+ webhookUrlForConnection: (connectionId) =>
61
+ `https://shipfox.example.test/webhooks/integrations/jira/${connectionId}`,
62
+ resolveConnection,
63
+ listInstallations,
64
+ updateInstallation,
65
+ now: () => now,
66
+ });
67
+
68
+ const result = await activities.renewJiraWebhooksActivity();
69
+
70
+ expect(listInstallations).toHaveBeenCalledWith({
71
+ before: new Date(now.getTime() + JIRA_WEBHOOK_RENEWAL_THRESHOLD_MS),
72
+ limit: 20,
73
+ });
74
+ expect(getAccessToken).toHaveBeenCalledWith({connectionId: dueConnectionId});
75
+ expect(refreshDynamicWebhooks).toHaveBeenCalledWith({
76
+ accessToken: 'fresh-access-token',
77
+ cloudId: due.cloudId,
78
+ webhookIds: due.webhookIds,
79
+ });
80
+ expect(updateInstallation).toHaveBeenCalledWith({
81
+ connectionId: dueConnectionId,
82
+ webhookIds: due.webhookIds,
83
+ webhookExpiresAt: expirationDate,
84
+ expectedWebhookIds: due.webhookIds,
85
+ expectedWebhookExpiresAt: due.webhookExpiresAt,
86
+ });
87
+ expect(result).toEqual({renewed: 1, reregistered: 0, skipped: 2, failed: 0});
88
+ });
89
+
90
+ it('re-registers and replaces webhook metadata when Jira no longer refreshes the ids', async () => {
91
+ const connectionId = crypto.randomUUID();
92
+ const due = installation(connectionId);
93
+ const registerWebhook = vi.fn().mockResolvedValue({
94
+ webhookId: 456,
95
+ webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
96
+ });
97
+ const now = new Date('2026-08-01T00:00:00.000Z');
98
+ const jira = {
99
+ refreshDynamicWebhooks: vi.fn().mockResolvedValue(undefined),
100
+ registerDynamicWebhook: vi.fn(),
101
+ deleteDynamicWebhook: vi.fn(),
102
+ };
103
+ const activities = createJiraWebhookRenewalActivities({
104
+ tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
105
+ jira,
106
+ webhookUrlForConnection: (id) =>
107
+ `https://shipfox.example.test/webhooks/integrations/jira/${id}`,
108
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
109
+ listInstallations: vi.fn().mockResolvedValue([due]),
110
+ registerWebhook,
111
+ now: () => now,
112
+ });
113
+
114
+ const result = await activities.renewJiraWebhooksActivity();
115
+
116
+ expect(registerWebhook).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ connectionId,
119
+ cloudId: due.cloudId,
120
+ accessToken: 'fresh-access-token',
121
+ webhookUrl: `https://shipfox.example.test/webhooks/integrations/jira/${connectionId}`,
122
+ replaceExistingWebhooks: true,
123
+ }),
124
+ );
125
+ expect(result).toEqual({renewed: 0, reregistered: 1, skipped: 0, failed: 0});
126
+ });
127
+
128
+ it('counts a failure and keeps sweeping when one connection cannot be renewed', async () => {
129
+ const brokenConnectionId = crypto.randomUUID();
130
+ const healthyConnectionId = crypto.randomUUID();
131
+ const expirationDate = new Date('2026-08-31T00:00:00.000Z');
132
+ const broken = installation(brokenConnectionId);
133
+ const healthy = installation(healthyConnectionId);
134
+ const refreshDynamicWebhooks = vi
135
+ .fn()
136
+ .mockImplementation(({cloudId}: {cloudId: string}) =>
137
+ cloudId === broken.cloudId
138
+ ? Promise.reject(new Error('Jira rejected'))
139
+ : Promise.resolve(expirationDate),
140
+ );
141
+ const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
142
+ const activities = createJiraWebhookRenewalActivities({
143
+ tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
144
+ jira: {
145
+ refreshDynamicWebhooks,
146
+ registerDynamicWebhook: vi.fn(),
147
+ deleteDynamicWebhook: vi.fn(),
148
+ },
149
+ webhookUrlForConnection: (id) =>
150
+ `https://shipfox.example.test/webhooks/integrations/jira/${id}`,
151
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
152
+ listInstallations: vi.fn().mockResolvedValue([broken, healthy]),
153
+ updateInstallation,
154
+ now: () => new Date('2026-08-01T00:00:00.000Z'),
155
+ });
156
+
157
+ const result = await activities.renewJiraWebhooksActivity();
158
+
159
+ expect(updateInstallation).toHaveBeenCalledOnce();
160
+ expect(updateInstallation).toHaveBeenCalledWith(
161
+ expect.objectContaining({connectionId: healthyConnectionId}),
162
+ );
163
+ expect(result).toEqual({renewed: 1, reregistered: 0, skipped: 0, failed: 1});
164
+ });
165
+
166
+ it('fails the sweep entry when webhook renewal dependencies are not configured', async () => {
167
+ const due = installation(crypto.randomUUID());
168
+ const activities = createJiraWebhookRenewalActivities({
169
+ tokenStore: {getAccessToken: vi.fn()},
170
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
171
+ listInstallations: vi.fn().mockResolvedValue([due]),
172
+ now: () => new Date('2026-08-01T00:00:00.000Z'),
173
+ });
174
+
175
+ await expect(activities.renewJiraWebhooksActivity()).resolves.toEqual({
176
+ renewed: 0,
177
+ reregistered: 0,
178
+ skipped: 0,
179
+ failed: 1,
180
+ });
181
+ });
182
+
183
+ it('skips a stale refresh result when webhook metadata changed during the request', async () => {
184
+ const connectionId = crypto.randomUUID();
185
+ const due = installation(connectionId);
186
+ const updateInstallation = vi.fn().mockResolvedValue(undefined);
187
+ const activities = createJiraWebhookRenewalActivities({
188
+ tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
189
+ jira: {
190
+ refreshDynamicWebhooks: vi.fn().mockResolvedValue(new Date('2026-08-31T00:00:00.000Z')),
191
+ registerDynamicWebhook: vi.fn(),
192
+ deleteDynamicWebhook: vi.fn(),
193
+ },
194
+ webhookUrlForConnection: (id) => `https://shipfox.example.test/${id}`,
195
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
196
+ listInstallations: vi.fn().mockResolvedValue([due]),
197
+ updateInstallation,
198
+ now: () => new Date('2026-08-01T00:00:00.000Z'),
199
+ });
200
+
201
+ await expect(activities.renewJiraWebhooksActivity()).resolves.toEqual({
202
+ renewed: 0,
203
+ reregistered: 0,
204
+ skipped: 1,
205
+ failed: 0,
206
+ });
207
+ expect(updateInstallation).toHaveBeenCalledWith({
208
+ connectionId,
209
+ webhookIds: due.webhookIds,
210
+ webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
211
+ expectedWebhookIds: due.webhookIds,
212
+ expectedWebhookExpiresAt: due.webhookExpiresAt,
213
+ });
214
+ });
215
+ });