@shipfox/api-integration-jira 11.0.0 → 12.1.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 (126) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +34 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +131 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/config.d.ts +0 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -4
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-tools-provider.d.ts +26 -0
  13. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  14. package/dist/core/agent-tools-provider.js +130 -0
  15. package/dist/core/agent-tools-provider.js.map +1 -0
  16. package/dist/core/agent-tools.d.ts +16 -0
  17. package/dist/core/agent-tools.d.ts.map +1 -0
  18. package/dist/core/agent-tools.js +441 -0
  19. package/dist/core/agent-tools.js.map +1 -0
  20. package/dist/core/install.d.ts +22 -0
  21. package/dist/core/install.d.ts.map +1 -1
  22. package/dist/core/install.js +68 -20
  23. package/dist/core/install.js.map +1 -1
  24. package/dist/core/tokens.d.ts +2 -0
  25. package/dist/core/tokens.d.ts.map +1 -1
  26. package/dist/core/tokens.js +129 -37
  27. package/dist/core/tokens.js.map +1 -1
  28. package/dist/core/webhook-processor.d.ts +15 -0
  29. package/dist/core/webhook-processor.d.ts.map +1 -0
  30. package/dist/core/webhook-processor.js +137 -0
  31. package/dist/core/webhook-processor.js.map +1 -0
  32. package/dist/core/webhook-registration.d.ts +26 -0
  33. package/dist/core/webhook-registration.d.ts.map +1 -0
  34. package/dist/core/webhook-registration.js +165 -0
  35. package/dist/core/webhook-registration.js.map +1 -0
  36. package/dist/core/webhook.d.ts +18 -0
  37. package/dist/core/webhook.d.ts.map +1 -0
  38. package/dist/core/webhook.js +38 -0
  39. package/dist/core/webhook.js.map +1 -0
  40. package/dist/db/db.d.ts +68 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/installations.d.ts +33 -6
  43. package/dist/db/installations.d.ts.map +1 -1
  44. package/dist/db/installations.js +79 -7
  45. package/dist/db/installations.js.map +1 -1
  46. package/dist/db/schema/installations.d.ts +34 -0
  47. package/dist/db/schema/installations.d.ts.map +1 -1
  48. package/dist/db/schema/installations.js +12 -2
  49. package/dist/db/schema/installations.js.map +1 -1
  50. package/dist/index.d.ts +34 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +76 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/presentation/routes/install.d.ts +22 -0
  55. package/dist/presentation/routes/install.d.ts.map +1 -1
  56. package/dist/presentation/routes/install.js.map +1 -1
  57. package/dist/presentation/routes/webhooks.d.ts +8 -0
  58. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  59. package/dist/presentation/routes/webhooks.js +108 -0
  60. package/dist/presentation/routes/webhooks.js.map +1 -0
  61. package/dist/temporal/activities/index.d.ts +2 -0
  62. package/dist/temporal/activities/index.d.ts.map +1 -0
  63. package/dist/temporal/activities/index.js +3 -0
  64. package/dist/temporal/activities/index.js.map +1 -0
  65. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  66. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  67. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  68. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  69. package/dist/temporal/constants.d.ts +6 -0
  70. package/dist/temporal/constants.d.ts.map +1 -0
  71. package/dist/temporal/constants.js +8 -0
  72. package/dist/temporal/constants.js.map +1 -0
  73. package/dist/temporal/worker.d.ts +9 -0
  74. package/dist/temporal/worker.d.ts.map +1 -0
  75. package/dist/temporal/worker.js +26 -0
  76. package/dist/temporal/worker.js.map +1 -0
  77. package/dist/temporal/workflows/index.bundle.js +25225 -0
  78. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  79. package/dist/temporal/workflows/index.d.ts +2 -0
  80. package/dist/temporal/workflows/index.d.ts.map +1 -0
  81. package/dist/temporal/workflows/index.js +3 -0
  82. package/dist/temporal/workflows/index.js.map +1 -0
  83. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  84. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  85. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  86. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  89. package/drizzle/meta/0001_snapshot.json +245 -0
  90. package/drizzle/meta/_journal.json +7 -0
  91. package/package.json +17 -8
  92. package/src/api/client.test.ts +236 -9
  93. package/src/api/client.ts +192 -0
  94. package/src/config.test.ts +0 -1
  95. package/src/config.ts +1 -4
  96. package/src/core/agent-tools-provider.test.ts +299 -0
  97. package/src/core/agent-tools-provider.ts +175 -0
  98. package/src/core/agent-tools.ts +450 -0
  99. package/src/core/install.test.ts +179 -0
  100. package/src/core/install.ts +85 -19
  101. package/src/core/tokens-refresh.test.ts +223 -4
  102. package/src/core/tokens.test.ts +1 -1
  103. package/src/core/tokens.ts +153 -33
  104. package/src/core/webhook-processor.test.ts +418 -0
  105. package/src/core/webhook-processor.ts +166 -0
  106. package/src/core/webhook-registration.test.ts +375 -0
  107. package/src/core/webhook-registration.ts +216 -0
  108. package/src/core/webhook.ts +69 -0
  109. package/src/db/installations.test.ts +148 -2
  110. package/src/db/installations.ts +133 -7
  111. package/src/db/schema/installations.ts +21 -1
  112. package/src/index.test.ts +42 -0
  113. package/src/index.ts +148 -5
  114. package/src/presentation/routes/install.ts +12 -0
  115. package/src/presentation/routes/webhooks.test.ts +132 -0
  116. package/src/presentation/routes/webhooks.ts +115 -0
  117. package/src/temporal/activities/index.ts +5 -0
  118. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  119. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  120. package/src/temporal/constants.ts +8 -0
  121. package/src/temporal/worker.ts +39 -0
  122. package/src/temporal/workflows/index.ts +1 -0
  123. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  124. package/test/env.ts +0 -1
  125. package/test/factories/jira-installation.ts +4 -0
  126. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,166 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {createHash} from 'node:crypto';
3
+ import {
4
+ type JiraWebhookEnvelopeDto,
5
+ jiraWebhookEnvelopeSchema,
6
+ } from '@shipfox/api-integration-jira-dto';
7
+ import {
8
+ decodeWebhookBody,
9
+ type GetIntegrationConnectionByIdFn,
10
+ type PublishIntegrationEventReceivedFn,
11
+ type RecordDeliveryOnlyFn,
12
+ type StoredWebhookRequest,
13
+ type WebhookProcessingResult,
14
+ } from '@shipfox/api-integration-spi';
15
+ import {extractBearerToken} from '@shipfox/node-fastify';
16
+ import {verifyHs256} from '@shipfox/node-jwt';
17
+ import {logger} from '@shipfox/node-opentelemetry';
18
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
19
+ import {z} from 'zod';
20
+ import {config} from '#config.js';
21
+ import {handleJiraWebhook, isJiraInstallationUsable} from '#core/webhook.js';
22
+ import {getJiraInstallationByConnectionId} from '#db/installations.js';
23
+
24
+ const JIRA_PROVIDER = 'jira';
25
+ const jiraWebhookJwtClaimsSchema = z
26
+ .object({iat: z.number().int(), exp: z.number().int()})
27
+ .passthrough();
28
+
29
+ export interface CreateJiraWebhookProcessorOptions {
30
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
31
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
32
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
33
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
34
+ getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId;
35
+ }
36
+
37
+ export interface JiraWebhookProcessor {
38
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
39
+ }
40
+
41
+ export function createJiraWebhookProcessor(
42
+ options: CreateJiraWebhookProcessorOptions,
43
+ ): JiraWebhookProcessor {
44
+ return {process: (request) => processJiraWebhookRequest(options, request)};
45
+ }
46
+
47
+ async function processJiraWebhookRequest(
48
+ options: CreateJiraWebhookProcessorOptions,
49
+ request: StoredWebhookRequest,
50
+ ): Promise<WebhookProcessingResult> {
51
+ if (request.route_id !== 'jira') {
52
+ throw new Error(`Jira processor cannot process ${request.route_id} requests`);
53
+ }
54
+
55
+ const rawBody = Buffer.from(decodeWebhookBody(request.body));
56
+ const deliveryId = jiraDeliveryId(request, rawBody);
57
+ const authorization = request.headers.authorization;
58
+ if (!authorization) return invalidAuthorization(deliveryId);
59
+
60
+ const token = extractBearerToken(authorization);
61
+ if (!token) return invalidAuthorization(deliveryId);
62
+
63
+ try {
64
+ await verifyHs256({
65
+ token,
66
+ secret: config.JIRA_OAUTH_CLIENT_SECRET,
67
+ schema: jiraWebhookJwtClaimsSchema,
68
+ verificationTime: new Date(request.received_at),
69
+ });
70
+ } catch (error) {
71
+ logger().warn(
72
+ {deliveryId, errName: error instanceof Error ? error.name : typeof error},
73
+ 'Jira webhook authorization verification failed',
74
+ );
75
+ return invalidAuthorization(deliveryId);
76
+ }
77
+
78
+ let rawPayload: unknown;
79
+ try {
80
+ rawPayload = JSON.parse(rawBody.toString('utf8'));
81
+ } catch (error) {
82
+ logger().warn({deliveryId, err: error}, 'Jira webhook payload JSON parse failed');
83
+ return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};
84
+ }
85
+
86
+ const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);
87
+ if (!payload.success) {
88
+ await recordDeliveryOnly(options, deliveryId);
89
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
90
+ }
91
+
92
+ const connectionId = request.path_parameters.connection_id;
93
+ const connection = await options.getIntegrationConnectionById(connectionId);
94
+ if (
95
+ !connection ||
96
+ connection.provider !== JIRA_PROVIDER ||
97
+ connection.lifecycleStatus !== 'active'
98
+ ) {
99
+ return await discardUnavailableConnection(options, deliveryId);
100
+ }
101
+ const installation = await (
102
+ options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId
103
+ )(connectionId);
104
+ if (
105
+ !isJiraInstallationUsable(installation) ||
106
+ !hasMatchingWebhookId(payload.data, installation.webhookIds)
107
+ ) {
108
+ return await discardUnavailableConnection(options, deliveryId);
109
+ }
110
+
111
+ const result = await options.coreDb().transaction(async (tx) =>
112
+ handleJiraWebhook({
113
+ tx,
114
+ deliveryId,
115
+ receivedAt: request.received_at,
116
+ rawPayload: payload.data,
117
+ cloudId: installation.cloudId,
118
+ connection: connection as typeof connection & {provider: 'jira'},
119
+ authorizingAccountId: installation.authorizingAccountId,
120
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
121
+ recordDeliveryOnly: options.recordDeliveryOnly,
122
+ }),
123
+ );
124
+
125
+ if (result === 'duplicate') return {outcome: 'duplicate', deliveryId};
126
+ if (result === 'discarded')
127
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
128
+ return {outcome: 'processed', deliveryId};
129
+ }
130
+
131
+ function invalidAuthorization(deliveryId: string): WebhookProcessingResult {
132
+ return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
133
+ }
134
+
135
+ function jiraDeliveryId(request: StoredWebhookRequest, rawBody: Uint8Array): string {
136
+ const connectionId = request.path_parameters.connection_id;
137
+ const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];
138
+ if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;
139
+ return createHash('sha256').update(connectionId).update('\0').update(rawBody).digest('hex');
140
+ }
141
+
142
+ async function recordDeliveryOnly(
143
+ options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,
144
+ deliveryId: string,
145
+ ): Promise<void> {
146
+ await options.coreDb().transaction(async (tx) => {
147
+ await options.recordDeliveryOnly({tx, provider: JIRA_PROVIDER, deliveryId});
148
+ });
149
+ }
150
+
151
+ async function discardUnavailableConnection(
152
+ options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,
153
+ deliveryId: string,
154
+ ): Promise<WebhookProcessingResult> {
155
+ await recordDeliveryOnly(options, deliveryId);
156
+ return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};
157
+ }
158
+
159
+ function hasMatchingWebhookId(
160
+ payload: JiraWebhookEnvelopeDto,
161
+ storedWebhookIds: number[],
162
+ ): boolean {
163
+ return (
164
+ payload.matchedWebhookIds?.some((webhookId) => storedWebhookIds.includes(webhookId)) === true
165
+ );
166
+ }
@@ -0,0 +1,375 @@
1
+ import {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from './webhook-registration.js';
2
+
3
+ const runInlineRegistrationLock = <T>(_lockKey: string, fn: () => Promise<T>) => fn();
4
+
5
+ describe('registerJiraWebhook', () => {
6
+ it('persists the returned id with a 30-day expiry', async () => {
7
+ const registerDynamicWebhook = vi.fn().mockResolvedValue({webhookId: 123});
8
+ const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
9
+ const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
10
+ const getInstallation = vi.fn().mockResolvedValue(undefined);
11
+ const now = new Date('2030-01-01T00:00:00.000Z');
12
+
13
+ const result = await registerJiraWebhook({
14
+ jira: {registerDynamicWebhook, deleteDynamicWebhook},
15
+ connectionId: 'connection-1',
16
+ cloudId: 'cloud-1',
17
+ accessToken: 'access-token',
18
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
19
+ now: () => now,
20
+ getInstallation,
21
+ updateInstallation,
22
+ withRegistrationLock: (_lockKey, fn) => fn(),
23
+ });
24
+
25
+ expect(registerDynamicWebhook).toHaveBeenCalledWith({
26
+ accessToken: 'access-token',
27
+ cloudId: 'cloud-1',
28
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
29
+ });
30
+ expect(getInstallation).toHaveBeenCalledWith('connection-1');
31
+ expect(updateInstallation).toHaveBeenCalledWith({
32
+ connectionId: 'connection-1',
33
+ webhookIds: [123],
34
+ webhookExpiresAt: new Date(now.getTime() + JIRA_WEBHOOK_TTL_MS),
35
+ });
36
+ expect(result.webhookId).toBe(123);
37
+ expect(deleteDynamicWebhook).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('deletes the remote webhook when local metadata persistence fails', async () => {
41
+ const registerDynamicWebhook = vi.fn().mockResolvedValue({webhookId: 456});
42
+ const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
43
+ const persistenceError = new Error('database unavailable');
44
+
45
+ await expect(
46
+ registerJiraWebhook({
47
+ jira: {registerDynamicWebhook, deleteDynamicWebhook},
48
+ connectionId: 'connection-1',
49
+ cloudId: 'cloud-1',
50
+ accessToken: 'access-token',
51
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
52
+ getInstallation: vi.fn().mockResolvedValue(undefined),
53
+ updateInstallation: vi.fn().mockRejectedValue(persistenceError),
54
+ withRegistrationLock: runInlineRegistrationLock,
55
+ }),
56
+ ).rejects.toBe(persistenceError);
57
+
58
+ expect(deleteDynamicWebhook).toHaveBeenCalledWith({
59
+ accessToken: 'access-token',
60
+ cloudId: 'cloud-1',
61
+ webhookId: 456,
62
+ });
63
+ });
64
+
65
+ it('does not mask the metadata error when remote cleanup fails', async () => {
66
+ const persistenceError = new Error('database unavailable');
67
+ const cleanupError = new Error('provider unavailable');
68
+
69
+ await expect(
70
+ registerJiraWebhook({
71
+ jira: {
72
+ registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 789}),
73
+ deleteDynamicWebhook: vi.fn().mockRejectedValue(cleanupError),
74
+ },
75
+ connectionId: 'connection-1',
76
+ cloudId: 'cloud-1',
77
+ accessToken: 'access-token',
78
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
79
+ getInstallation: vi.fn().mockResolvedValue(undefined),
80
+ updateInstallation: vi.fn().mockRejectedValue(persistenceError),
81
+ withRegistrationLock: runInlineRegistrationLock,
82
+ }),
83
+ ).rejects.toBe(persistenceError);
84
+ });
85
+
86
+ it('compensates and restores metadata when activation rejects', async () => {
87
+ const activationError = new Error('connection status update failed');
88
+ const previousExpiry = new Date('2030-01-01T00:00:00.000Z');
89
+ const updateInstallation = vi
90
+ .fn()
91
+ .mockResolvedValueOnce({id: 'installation-1'})
92
+ .mockResolvedValueOnce({id: 'installation-1'});
93
+ const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
94
+
95
+ await expect(
96
+ registerJiraWebhook({
97
+ jira: {
98
+ registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
99
+ deleteDynamicWebhook,
100
+ },
101
+ connectionId: 'connection-1',
102
+ cloudId: 'cloud-1',
103
+ accessToken: 'access-token',
104
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
105
+ getInstallation: vi.fn().mockResolvedValue({
106
+ webhookIds: [123],
107
+ webhookExpiresAt: previousExpiry,
108
+ }),
109
+ updateInstallation,
110
+ withRegistrationLock: runInlineRegistrationLock,
111
+ onRegistrationSuccess: vi.fn().mockRejectedValue(activationError),
112
+ }),
113
+ ).rejects.toBe(activationError);
114
+
115
+ expect(deleteDynamicWebhook).toHaveBeenCalledWith({
116
+ accessToken: 'access-token',
117
+ cloudId: 'cloud-1',
118
+ webhookId: 456,
119
+ });
120
+ expect(updateInstallation).toHaveBeenNthCalledWith(2, {
121
+ connectionId: 'connection-1',
122
+ webhookIds: [123],
123
+ webhookExpiresAt: previousExpiry,
124
+ });
125
+ });
126
+
127
+ it('compensates after the lock wrapper rejects while committing a successful registration', async () => {
128
+ const commitError = new Error('transaction commit failed');
129
+ const previousExpiry = new Date('2030-01-01T00:00:00.000Z');
130
+ const updateInstallation = vi
131
+ .fn()
132
+ .mockResolvedValueOnce({id: 'installation-1'})
133
+ .mockResolvedValueOnce({id: 'installation-1'});
134
+ const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
135
+ const withRegistrationLock = async <T>(_lockKey: string, fn: () => Promise<T>) => {
136
+ await fn();
137
+ throw commitError;
138
+ };
139
+
140
+ await expect(
141
+ registerJiraWebhook({
142
+ jira: {
143
+ registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 789}),
144
+ deleteDynamicWebhook,
145
+ },
146
+ connectionId: 'connection-1',
147
+ cloudId: 'cloud-1',
148
+ accessToken: 'access-token',
149
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
150
+ getInstallation: vi.fn().mockResolvedValue({
151
+ webhookIds: [123],
152
+ webhookExpiresAt: previousExpiry,
153
+ }),
154
+ updateInstallation,
155
+ withRegistrationLock,
156
+ }),
157
+ ).rejects.toBe(commitError);
158
+
159
+ expect(deleteDynamicWebhook).toHaveBeenCalledWith({
160
+ accessToken: 'access-token',
161
+ cloudId: 'cloud-1',
162
+ webhookId: 789,
163
+ });
164
+ expect(deleteDynamicWebhook).toHaveBeenCalledTimes(1);
165
+ expect(updateInstallation).toHaveBeenNthCalledWith(2, {
166
+ connectionId: 'connection-1',
167
+ webhookIds: [123],
168
+ webhookExpiresAt: previousExpiry,
169
+ });
170
+ });
171
+
172
+ it('runs the error-state transition before releasing the registration lock', async () => {
173
+ let lockCallbackCompleted = false;
174
+ const withRegistrationLock = async <T>(_lockKey: string, fn: () => Promise<T>) => {
175
+ try {
176
+ return await fn();
177
+ } finally {
178
+ lockCallbackCompleted = true;
179
+ }
180
+ };
181
+ const onRegistrationFailure = vi.fn().mockResolvedValue(undefined);
182
+
183
+ await expect(
184
+ registerJiraWebhook({
185
+ jira: {
186
+ registerDynamicWebhook: vi.fn().mockRejectedValue(new Error('provider unavailable')),
187
+ deleteDynamicWebhook: vi.fn(),
188
+ },
189
+ connectionId: 'connection-1',
190
+ cloudId: 'cloud-1',
191
+ accessToken: 'access-token',
192
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
193
+ getInstallation: vi.fn().mockResolvedValue(undefined),
194
+ updateInstallation: vi.fn(),
195
+ withRegistrationLock,
196
+ onRegistrationFailure,
197
+ }),
198
+ ).rejects.toThrow('provider unavailable');
199
+
200
+ expect(lockCallbackCompleted).toBe(true);
201
+ expect(onRegistrationFailure).toHaveBeenCalledWith({});
202
+ });
203
+
204
+ it('persists the replacement before deleting all superseded remote webhooks', async () => {
205
+ const calls: string[] = [];
206
+ const updateInstallation = vi.fn().mockImplementation(() => {
207
+ calls.push('persist');
208
+ return Promise.resolve({id: 'installation-1'});
209
+ });
210
+ const deleteDynamicWebhook = vi.fn().mockImplementation(({webhookId}) => {
211
+ calls.push(`delete:${webhookId}`);
212
+ return Promise.resolve();
213
+ });
214
+ const result = await registerJiraWebhook({
215
+ jira: {
216
+ registerDynamicWebhook: vi.fn().mockImplementation(() => {
217
+ calls.push('register');
218
+ return Promise.resolve({webhookId: 456});
219
+ }),
220
+ deleteDynamicWebhook,
221
+ },
222
+ connectionId: 'connection-1',
223
+ cloudId: 'cloud-1',
224
+ accessToken: 'access-token',
225
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
226
+ getInstallation: vi.fn().mockResolvedValue({webhookIds: [123, 234]}),
227
+ updateInstallation,
228
+ withRegistrationLock: runInlineRegistrationLock,
229
+ });
230
+
231
+ expect(result.webhookId).toBe(456);
232
+ expect(calls).toEqual(['register', 'persist', 'delete:123', 'delete:234', 'persist']);
233
+ expect(updateInstallation).toHaveBeenNthCalledWith(
234
+ 1,
235
+ expect.objectContaining({webhookIds: [456, 123, 234]}),
236
+ );
237
+ expect(updateInstallation).toHaveBeenNthCalledWith(
238
+ 2,
239
+ expect.objectContaining({webhookIds: [456]}),
240
+ );
241
+ expect(deleteDynamicWebhook).toHaveBeenCalledTimes(2);
242
+ });
243
+
244
+ it('retains failed superseded webhook cleanup ids for a later retry', async () => {
245
+ const deleteDynamicWebhook = vi
246
+ .fn()
247
+ .mockImplementation(({webhookId}) =>
248
+ webhookId === 234 ? Promise.reject(new Error('provider unavailable')) : Promise.resolve(),
249
+ );
250
+ const updateInstallation = vi
251
+ .fn()
252
+ .mockResolvedValueOnce({id: 'installation-1'})
253
+ .mockResolvedValueOnce({id: 'installation-1'});
254
+
255
+ await expect(
256
+ registerJiraWebhook({
257
+ jira: {
258
+ registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
259
+ deleteDynamicWebhook,
260
+ },
261
+ connectionId: 'connection-1',
262
+ cloudId: 'cloud-1',
263
+ accessToken: 'access-token',
264
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
265
+ getInstallation: vi.fn().mockResolvedValue({webhookIds: [123, 234]}),
266
+ updateInstallation,
267
+ withRegistrationLock: runInlineRegistrationLock,
268
+ }),
269
+ ).resolves.toMatchObject({webhookId: 456});
270
+
271
+ expect(updateInstallation).toHaveBeenNthCalledWith(
272
+ 2,
273
+ expect.objectContaining({webhookIds: [456, 234]}),
274
+ );
275
+ expect(deleteDynamicWebhook).toHaveBeenCalledTimes(2);
276
+ });
277
+
278
+ it('keeps conservative metadata when failed cleanup cannot be persisted', async () => {
279
+ const updateInstallation = vi
280
+ .fn()
281
+ .mockResolvedValueOnce({id: 'installation-1'})
282
+ .mockRejectedValueOnce(new Error('database unavailable'));
283
+
284
+ await expect(
285
+ registerJiraWebhook({
286
+ jira: {
287
+ registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
288
+ deleteDynamicWebhook: vi
289
+ .fn()
290
+ .mockImplementation(({webhookId}) =>
291
+ webhookId === 234
292
+ ? Promise.reject(new Error('provider unavailable'))
293
+ : Promise.resolve(),
294
+ ),
295
+ },
296
+ connectionId: 'connection-1',
297
+ cloudId: 'cloud-1',
298
+ accessToken: 'access-token',
299
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
300
+ getInstallation: vi.fn().mockResolvedValue({webhookIds: [123, 234]}),
301
+ updateInstallation,
302
+ withRegistrationLock: runInlineRegistrationLock,
303
+ }),
304
+ ).resolves.toMatchObject({webhookId: 456});
305
+
306
+ expect(updateInstallation).toHaveBeenNthCalledWith(
307
+ 1,
308
+ expect.objectContaining({webhookIds: [456, 123, 234]}),
309
+ );
310
+ });
311
+
312
+ it('serializes concurrent replacement and lifecycle transitions per connection', async () => {
313
+ let releaseFirstRegistration!: (error: Error) => void;
314
+ const firstRegistration = new Promise<never>((_, reject) => {
315
+ releaseFirstRegistration = reject;
316
+ });
317
+ const registration = vi
318
+ .fn()
319
+ .mockReturnValueOnce(firstRegistration)
320
+ .mockResolvedValueOnce({webhookId: 456});
321
+ const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
322
+ const events: string[] = [];
323
+ let lockTail = Promise.resolve();
324
+ const withRegistrationLock = async <T>(_lockKey: string, fn: () => Promise<T>) => {
325
+ const predecessor = lockTail;
326
+ let release!: () => void;
327
+ lockTail = new Promise<void>((resolve) => {
328
+ release = resolve;
329
+ });
330
+ await predecessor;
331
+ try {
332
+ return await fn();
333
+ } finally {
334
+ release();
335
+ }
336
+ };
337
+
338
+ const common = {
339
+ jira: {
340
+ registerDynamicWebhook: registration,
341
+ deleteDynamicWebhook: vi.fn().mockResolvedValue(undefined),
342
+ },
343
+ connectionId: 'connection-1',
344
+ cloudId: 'cloud-1',
345
+ accessToken: 'access-token',
346
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
347
+ getInstallation: vi.fn().mockResolvedValue(undefined),
348
+ updateInstallation,
349
+ withRegistrationLock,
350
+ };
351
+
352
+ const first = registerJiraWebhook({
353
+ ...common,
354
+ onRegistrationFailure: () => {
355
+ events.push('first-failure');
356
+ return Promise.resolve();
357
+ },
358
+ });
359
+ const second = registerJiraWebhook({
360
+ ...common,
361
+ onRegistrationSuccess: () => {
362
+ events.push('second-success');
363
+ return Promise.resolve();
364
+ },
365
+ });
366
+
367
+ await vi.waitFor(() => expect(registration).toHaveBeenCalledOnce());
368
+ releaseFirstRegistration(new Error('first registration failed'));
369
+
370
+ await expect(first).rejects.toThrow('first registration failed');
371
+ await expect(second).resolves.toMatchObject({webhookId: 456});
372
+ expect(events).toEqual(['first-failure', 'second-success']);
373
+ expect(updateInstallation).toHaveBeenCalledOnce();
374
+ });
375
+ });