@shipfox/api-triggers 2.0.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.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +224 -0
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +42 -0
- package/dist/config.js.map +1 -0
- package/dist/core/compute-next-fire-at.d.ts +9 -0
- package/dist/core/compute-next-fire-at.d.ts.map +1 -0
- package/dist/core/compute-next-fire-at.js +28 -0
- package/dist/core/compute-next-fire-at.js.map +1 -0
- package/dist/core/config.d.ts +26 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +68 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/dispatch-integration-event.d.ts +14 -0
- package/dist/core/dispatch-integration-event.d.ts.map +1 -0
- package/dist/core/dispatch-integration-event.js +136 -0
- package/dist/core/dispatch-integration-event.js.map +1 -0
- package/dist/core/drain-cron-schedules.d.ts +30 -0
- package/dist/core/drain-cron-schedules.d.ts.map +1 -0
- package/dist/core/drain-cron-schedules.js +76 -0
- package/dist/core/drain-cron-schedules.js.map +1 -0
- package/dist/core/entities/cron-schedule.d.ts +11 -0
- package/dist/core/entities/cron-schedule.d.ts.map +1 -0
- package/dist/core/entities/cron-schedule.js +3 -0
- package/dist/core/entities/cron-schedule.js.map +1 -0
- package/dist/core/entities/decision.d.ts +23 -0
- package/dist/core/entities/decision.d.ts.map +1 -0
- package/dist/core/entities/decision.js +7 -0
- package/dist/core/entities/decision.js.map +1 -0
- package/dist/core/entities/index.d.ts +3 -0
- package/dist/core/entities/index.d.ts.map +1 -0
- package/dist/core/entities/index.js +3 -0
- package/dist/core/entities/index.js.map +1 -0
- package/dist/core/entities/job-listener-subscription.d.ts +14 -0
- package/dist/core/entities/job-listener-subscription.d.ts.map +1 -0
- package/dist/core/entities/job-listener-subscription.js +3 -0
- package/dist/core/entities/job-listener-subscription.js.map +1 -0
- package/dist/core/entities/received-event.d.ts +28 -0
- package/dist/core/entities/received-event.d.ts.map +1 -0
- package/dist/core/entities/received-event.js +14 -0
- package/dist/core/entities/received-event.js.map +1 -0
- package/dist/core/entities/subscription.d.ts +13 -0
- package/dist/core/entities/subscription.d.ts.map +1 -0
- package/dist/core/entities/subscription.js +3 -0
- package/dist/core/entities/subscription.js.map +1 -0
- package/dist/core/errors.d.ts +25 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +41 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/fire-cron.d.ts +28 -0
- package/dist/core/fire-cron.d.ts.map +1 -0
- package/dist/core/fire-cron.js +85 -0
- package/dist/core/fire-cron.js.map +1 -0
- package/dist/core/fire-manual.d.ts +9 -0
- package/dist/core/fire-manual.d.ts.map +1 -0
- package/dist/core/fire-manual.js +87 -0
- package/dist/core/fire-manual.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/record-trigger-history.d.ts +35 -0
- package/dist/core/record-trigger-history.d.ts.map +1 -0
- package/dist/core/record-trigger-history.js +75 -0
- package/dist/core/record-trigger-history.js.map +1 -0
- package/dist/core/route-event-to-job-listeners.d.ts +31 -0
- package/dist/core/route-event-to-job-listeners.d.ts.map +1 -0
- package/dist/core/route-event-to-job-listeners.js +140 -0
- package/dist/core/route-event-to-job-listeners.js.map +1 -0
- package/dist/db/cron-schedules.d.ts +36 -0
- package/dist/db/cron-schedules.d.ts.map +1 -0
- package/dist/db/cron-schedules.js +104 -0
- package/dist/db/cron-schedules.js.map +1 -0
- package/dist/db/db.d.ts +2515 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +28 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/event-history.d.ts +50 -0
- package/dist/db/event-history.d.ts.map +1 -0
- package/dist/db/event-history.js +207 -0
- package/dist/db/event-history.js.map +1 -0
- package/dist/db/event-queries.d.ts +38 -0
- package/dist/db/event-queries.d.ts.map +1 -0
- package/dist/db/event-queries.js +78 -0
- package/dist/db/event-queries.js.map +1 -0
- package/dist/db/index.d.ts +11 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +14 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/job-listener-subscriptions.d.ts +24 -0
- package/dist/db/job-listener-subscriptions.d.ts.map +1 -0
- package/dist/db/job-listener-subscriptions.js +69 -0
- package/dist/db/job-listener-subscriptions.js.map +1 -0
- package/dist/db/received-events.d.ts +7 -0
- package/dist/db/received-events.d.ts.map +1 -0
- package/dist/db/received-events.js +11 -0
- package/dist/db/received-events.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/cron-schedules.d.ts +148 -0
- package/dist/db/schema/cron-schedules.d.ts.map +1 -0
- package/dist/db/schema/cron-schedules.js +39 -0
- package/dist/db/schema/cron-schedules.js.map +1 -0
- package/dist/db/schema/decisions.d.ts +284 -0
- package/dist/db/schema/decisions.d.ts.map +1 -0
- package/dist/db/schema/decisions.js +70 -0
- package/dist/db/schema/decisions.js.map +1 -0
- package/dist/db/schema/job-listener-subscriptions.d.ts +185 -0
- package/dist/db/schema/job-listener-subscriptions.d.ts.map +1 -0
- package/dist/db/schema/job-listener-subscriptions.js +41 -0
- package/dist/db/schema/job-listener-subscriptions.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/received-events.d.ts +544 -0
- package/dist/db/schema/received-events.d.ts.map +1 -0
- package/dist/db/schema/received-events.js +86 -0
- package/dist/db/schema/received-events.js.map +1 -0
- package/dist/db/schema/subscriptions.d.ts +182 -0
- package/dist/db/schema/subscriptions.d.ts.map +1 -0
- package/dist/db/schema/subscriptions.js +39 -0
- package/dist/db/schema/subscriptions.js.map +1 -0
- package/dist/db/subscriptions.d.ts +26 -0
- package/dist/db/subscriptions.d.ts.map +1 -0
- package/dist/db/subscriptions.js +103 -0
- package/dist/db/subscriptions.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +4 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +15 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +32 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +15 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/trigger-events.d.ts +7 -0
- package/dist/presentation/dto/trigger-events.d.ts.map +1 -0
- package/dist/presentation/dto/trigger-events.js +47 -0
- package/dist/presentation/dto/trigger-events.js.map +1 -0
- package/dist/presentation/index.d.ts +3 -0
- package/dist/presentation/index.d.ts.map +1 -0
- package/dist/presentation/index.js +4 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/presentation/routes/fire-manual.d.ts +2 -0
- package/dist/presentation/routes/fire-manual.d.ts.map +1 -0
- package/dist/presentation/routes/fire-manual.js +71 -0
- package/dist/presentation/routes/fire-manual.js.map +1 -0
- package/dist/presentation/routes/get-trigger-event.d.ts +2 -0
- package/dist/presentation/routes/get-trigger-event.d.ts.map +1 -0
- package/dist/presentation/routes/get-trigger-event.js +37 -0
- package/dist/presentation/routes/get-trigger-event.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +27 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/list-trigger-event-facets.d.ts +2 -0
- package/dist/presentation/routes/list-trigger-event-facets.d.ts.map +1 -0
- package/dist/presentation/routes/list-trigger-event-facets.js +29 -0
- package/dist/presentation/routes/list-trigger-event-facets.js.map +1 -0
- package/dist/presentation/routes/list-trigger-events.d.ts +2 -0
- package/dist/presentation/routes/list-trigger-events.d.ts.map +1 -0
- package/dist/presentation/routes/list-trigger-events.js +56 -0
- package/dist/presentation/routes/list-trigger-events.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +6 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -0
- package/dist/presentation/subscribers/index.js +7 -0
- package/dist/presentation/subscribers/index.js.map +1 -0
- package/dist/presentation/subscribers/on-definition-deleted.d.ts +3 -0
- package/dist/presentation/subscribers/on-definition-deleted.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-definition-deleted.js +8 -0
- package/dist/presentation/subscribers/on-definition-deleted.js.map +1 -0
- package/dist/presentation/subscribers/on-definition-resolved.d.ts +3 -0
- package/dist/presentation/subscribers/on-definition-resolved.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-definition-resolved.js +11 -0
- package/dist/presentation/subscribers/on-definition-resolved.js.map +1 -0
- package/dist/presentation/subscribers/on-integration-event-received.d.ts +4 -0
- package/dist/presentation/subscribers/on-integration-event-received.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-integration-event-received.js +17 -0
- package/dist/presentation/subscribers/on-integration-event-received.js.map +1 -0
- package/dist/presentation/subscribers/on-job-activated.d.ts +3 -0
- package/dist/presentation/subscribers/on-job-activated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-job-activated.js +13 -0
- package/dist/presentation/subscribers/on-job-activated.js.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts +3 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.js +6 -0
- package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
- package/dist/temporal/activities/drain-cron-batch.d.ts +4 -0
- package/dist/temporal/activities/drain-cron-batch.d.ts.map +1 -0
- package/dist/temporal/activities/drain-cron-batch.js +20 -0
- package/dist/temporal/activities/drain-cron-batch.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +10 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +15 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/prune-trigger-events.d.ts +4 -0
- package/dist/temporal/activities/prune-trigger-events.d.ts.map +1 -0
- package/dist/temporal/activities/prune-trigger-events.js +11 -0
- package/dist/temporal/activities/prune-trigger-events.js.map +1 -0
- package/dist/temporal/constants.d.ts +7 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +7 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/cron-tick-cron.d.ts +9 -0
- package/dist/temporal/workflows/cron-tick-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/cron-tick-cron.js +54 -0
- package/dist/temporal/workflows/cron-tick-cron.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +3 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +4 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/prune-trigger-events-cron.d.ts +2 -0
- package/dist/temporal/workflows/prune-trigger-events-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/prune-trigger-events-cron.js +18 -0
- package/dist/temporal/workflows/prune-trigger-events-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +109 -0
- package/drizzle/meta/0000_snapshot.json +905 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +14 -0
- package/package.json +83 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +53 -0
- package/src/core/compute-next-fire-at.test.ts +69 -0
- package/src/core/compute-next-fire-at.ts +37 -0
- package/src/core/config.test.ts +213 -0
- package/src/core/config.ts +86 -0
- package/src/core/dispatch-integration-event.history-failure.test.ts +66 -0
- package/src/core/dispatch-integration-event.test.ts +1006 -0
- package/src/core/dispatch-integration-event.ts +150 -0
- package/src/core/drain-cron-schedules.test.ts +265 -0
- package/src/core/drain-cron-schedules.ts +90 -0
- package/src/core/entities/cron-schedule.ts +10 -0
- package/src/core/entities/decision.ts +24 -0
- package/src/core/entities/index.ts +5 -0
- package/src/core/entities/job-listener-subscription.ts +14 -0
- package/src/core/entities/received-event.ts +36 -0
- package/src/core/entities/subscription.ts +12 -0
- package/src/core/errors.ts +63 -0
- package/src/core/fire-cron.test.ts +146 -0
- package/src/core/fire-cron.ts +98 -0
- package/src/core/fire-manual.test.ts +157 -0
- package/src/core/fire-manual.ts +92 -0
- package/src/core/index.ts +34 -0
- package/src/core/record-trigger-history.test.ts +159 -0
- package/src/core/record-trigger-history.ts +145 -0
- package/src/core/route-event-to-job-listeners.test.ts +369 -0
- package/src/core/route-event-to-job-listeners.ts +184 -0
- package/src/db/cron-schedules.test.ts +358 -0
- package/src/db/cron-schedules.ts +163 -0
- package/src/db/db.ts +31 -0
- package/src/db/event-history.test.ts +421 -0
- package/src/db/event-history.ts +279 -0
- package/src/db/event-queries.ts +140 -0
- package/src/db/index.ts +51 -0
- package/src/db/job-listener-subscriptions.test.ts +270 -0
- package/src/db/job-listener-subscriptions.ts +133 -0
- package/src/db/received-events.ts +16 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/cron-schedules.ts +37 -0
- package/src/db/schema/decisions.test.ts +279 -0
- package/src/db/schema/decisions.ts +77 -0
- package/src/db/schema/job-listener-subscriptions.ts +65 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/received-events.test.ts +135 -0
- package/src/db/schema/received-events.ts +93 -0
- package/src/db/schema/subscriptions.ts +46 -0
- package/src/db/subscriptions.test.ts +340 -0
- package/src/db/subscriptions.ts +179 -0
- package/src/index.ts +108 -0
- package/src/metrics/index.ts +8 -0
- package/src/metrics/instance.ts +33 -0
- package/src/metrics/service.ts +18 -0
- package/src/presentation/dto/trigger-events.test.ts +144 -0
- package/src/presentation/dto/trigger-events.ts +60 -0
- package/src/presentation/index.ts +6 -0
- package/src/presentation/routes/fire-manual.test.ts +118 -0
- package/src/presentation/routes/fire-manual.ts +70 -0
- package/src/presentation/routes/get-trigger-event.test.ts +184 -0
- package/src/presentation/routes/get-trigger-event.ts +37 -0
- package/src/presentation/routes/index.ts +21 -0
- package/src/presentation/routes/list-trigger-event-facets.test.ts +116 -0
- package/src/presentation/routes/list-trigger-event-facets.ts +29 -0
- package/src/presentation/routes/list-trigger-events.test.ts +379 -0
- package/src/presentation/routes/list-trigger-events.ts +68 -0
- package/src/presentation/subscribers/index.ts +5 -0
- package/src/presentation/subscribers/on-definition-deleted.test.ts +42 -0
- package/src/presentation/subscribers/on-definition-deleted.ts +6 -0
- package/src/presentation/subscribers/on-definition-resolved.test.ts +67 -0
- package/src/presentation/subscribers/on-definition-resolved.ts +11 -0
- package/src/presentation/subscribers/on-integration-event-received.test.ts +53 -0
- package/src/presentation/subscribers/on-integration-event-received.ts +21 -0
- package/src/presentation/subscribers/on-job-activated.ts +14 -0
- package/src/presentation/subscribers/on-job-terminated.ts +6 -0
- package/src/temporal/activities/drain-cron-batch.ts +20 -0
- package/src/temporal/activities/index.ts +15 -0
- package/src/temporal/activities/prune-trigger-events.test.ts +78 -0
- package/src/temporal/activities/prune-trigger-events.ts +9 -0
- package/src/temporal/constants.ts +7 -0
- package/src/temporal/workflows/cron-tick-cron.ts +56 -0
- package/src/temporal/workflows/index.ts +2 -0
- package/src/temporal/workflows/prune-trigger-events-cron.ts +17 -0
- package/test/env.ts +8 -0
- package/test/factories/cron-schedule.ts +34 -0
- package/test/factories/decision.ts +49 -0
- package/test/factories/job-listener-subscription.ts +42 -0
- package/test/factories/received-event.ts +49 -0
- package/test/factories/subscription.ts +38 -0
- package/test/globalSetup.ts +20 -0
- package/test/index.ts +5 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
import {eq} from 'drizzle-orm';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {triggersDecisions} from '#db/schema/decisions.js';
|
|
4
|
+
import {triggersReceivedEvents} from '#db/schema/received-events.js';
|
|
5
|
+
import {jobListenerSubscriptionFactory, triggerSubscriptionFactory} from '#test/index.js';
|
|
6
|
+
|
|
7
|
+
const runWorkflow = vi.fn();
|
|
8
|
+
const deliverEventToListener = vi.fn();
|
|
9
|
+
|
|
10
|
+
// Mock the package root but keep real-enough permanent-error classes + the classifier, so the
|
|
11
|
+
// dispatcher's permanent/transient branching is exercised without loading the workflows module graph.
|
|
12
|
+
vi.mock('@shipfox/api-workflows', () => {
|
|
13
|
+
class DefinitionNotFoundError extends Error {
|
|
14
|
+
constructor(definitionId: string) {
|
|
15
|
+
super(`Definition not found: ${definitionId}`);
|
|
16
|
+
this.name = 'DefinitionNotFoundError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
class ProjectMismatchError extends Error {
|
|
20
|
+
constructor(definitionProjectId: string, requestProjectId: string) {
|
|
21
|
+
super(
|
|
22
|
+
`Definition belongs to project ${definitionProjectId}, but request targets project ${requestProjectId}`,
|
|
23
|
+
);
|
|
24
|
+
this.name = 'ProjectMismatchError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
runWorkflow: (...args: unknown[]) => runWorkflow(...args),
|
|
29
|
+
deliverEventToListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
30
|
+
DefinitionNotFoundError,
|
|
31
|
+
ProjectMismatchError,
|
|
32
|
+
isPermanentRunWorkflowError: (error: unknown) =>
|
|
33
|
+
error instanceof DefinitionNotFoundError || error instanceof ProjectMismatchError,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
import {DefinitionNotFoundError, ProjectMismatchError} from '@shipfox/api-workflows';
|
|
38
|
+
|
|
39
|
+
// Import after mocks so the core dispatcher sees the spy.
|
|
40
|
+
const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js');
|
|
41
|
+
|
|
42
|
+
interface DispatchOverrides {
|
|
43
|
+
eventRef?: string;
|
|
44
|
+
workspaceId?: string;
|
|
45
|
+
provider?: string;
|
|
46
|
+
source?: string;
|
|
47
|
+
event?: string;
|
|
48
|
+
deliveryId?: string;
|
|
49
|
+
connectionId?: string;
|
|
50
|
+
payload?: unknown;
|
|
51
|
+
receivedAt?: Date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dispatch(overrides: DispatchOverrides = {}): Promise<void> {
|
|
55
|
+
return dispatchIntegrationEvent({
|
|
56
|
+
eventRef: overrides.eventRef ?? crypto.randomUUID(),
|
|
57
|
+
provider: overrides.provider ?? overrides.source ?? 'github',
|
|
58
|
+
source: overrides.source ?? 'github',
|
|
59
|
+
event: overrides.event ?? 'push',
|
|
60
|
+
workspaceId: overrides.workspaceId ?? crypto.randomUUID(),
|
|
61
|
+
connectionId: overrides.connectionId ?? crypto.randomUUID(),
|
|
62
|
+
connectionName: 'Acme Production',
|
|
63
|
+
deliveryId: overrides.deliveryId ?? crypto.randomUUID(),
|
|
64
|
+
receivedAt: overrides.receivedAt ?? new Date(),
|
|
65
|
+
payload: overrides.payload ?? {ref: 'main', headCommitSha: 'abc123'},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function receivedEvent(eventRef: string) {
|
|
70
|
+
const [row] = await db()
|
|
71
|
+
.select()
|
|
72
|
+
.from(triggersReceivedEvents)
|
|
73
|
+
.where(eq(triggersReceivedEvents.eventRef, eventRef));
|
|
74
|
+
return row;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function decisionsForEvent(receivedEventId: string) {
|
|
78
|
+
return db()
|
|
79
|
+
.select()
|
|
80
|
+
.from(triggersDecisions)
|
|
81
|
+
.where(eq(triggersDecisions.receivedEventId, receivedEventId));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('dispatchIntegrationEvent', () => {
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
runWorkflow.mockReset();
|
|
87
|
+
deliverEventToListener.mockReset();
|
|
88
|
+
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Build and test'});
|
|
89
|
+
deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('fires the workflow for each matching workspace subscription, regardless of project', async () => {
|
|
93
|
+
const workspaceId = crypto.randomUUID();
|
|
94
|
+
const subA = await triggerSubscriptionFactory.create({
|
|
95
|
+
workspaceId,
|
|
96
|
+
projectId: crypto.randomUUID(),
|
|
97
|
+
source: 'github',
|
|
98
|
+
event: 'push',
|
|
99
|
+
config: {},
|
|
100
|
+
});
|
|
101
|
+
const subB = await triggerSubscriptionFactory.create({
|
|
102
|
+
workspaceId,
|
|
103
|
+
projectId: crypto.randomUUID(),
|
|
104
|
+
source: 'github',
|
|
105
|
+
event: 'push',
|
|
106
|
+
config: {},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await dispatch({workspaceId});
|
|
110
|
+
|
|
111
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
112
|
+
const firedProjects = runWorkflow.mock.calls.map(([params]) => params.projectId);
|
|
113
|
+
expect(firedProjects).toEqual(expect.arrayContaining([subA.projectId, subB.projectId]));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('passes the source, event, deliveryId and raw payload through as the trigger payload', async () => {
|
|
117
|
+
const workspaceId = crypto.randomUUID();
|
|
118
|
+
const deliveryId = crypto.randomUUID();
|
|
119
|
+
const payload = {ref: 'refs/heads/feature', headCommitSha: 'deadbeef'};
|
|
120
|
+
await triggerSubscriptionFactory.create({
|
|
121
|
+
workspaceId,
|
|
122
|
+
source: 'github',
|
|
123
|
+
event: 'push',
|
|
124
|
+
config: {},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await dispatch({workspaceId, deliveryId, payload});
|
|
128
|
+
|
|
129
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
triggerPayload: {
|
|
132
|
+
provider: 'github',
|
|
133
|
+
source: 'github',
|
|
134
|
+
event: 'push',
|
|
135
|
+
deliveryId,
|
|
136
|
+
data: payload,
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('dispatches an arbitrary non-github source without any source-specific handling', async () => {
|
|
143
|
+
const workspaceId = crypto.randomUUID();
|
|
144
|
+
await triggerSubscriptionFactory.create({
|
|
145
|
+
workspaceId,
|
|
146
|
+
source: 'sentry',
|
|
147
|
+
event: 'alert_triggered',
|
|
148
|
+
config: {},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await dispatch({workspaceId, source: 'sentry', event: 'alert_triggered'});
|
|
152
|
+
|
|
153
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
triggerPayload: expect.objectContaining({source: 'sentry', event: 'alert_triggered'}),
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('dispatches a Linear data webhook event to a matching trigger subscription', async () => {
|
|
162
|
+
const workspaceId = crypto.randomUUID();
|
|
163
|
+
const deliveryId = crypto.randomUUID();
|
|
164
|
+
const payload = {
|
|
165
|
+
action: 'create',
|
|
166
|
+
type: 'Issue',
|
|
167
|
+
organizationId: 'linear-org-id',
|
|
168
|
+
webhookTimestamp: Date.now(),
|
|
169
|
+
data: {
|
|
170
|
+
id: 'issue-id',
|
|
171
|
+
identifier: 'ENG-876',
|
|
172
|
+
title: 'Receive and verify Linear webhooks',
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
176
|
+
workspaceId,
|
|
177
|
+
source: 'Linear_Acme',
|
|
178
|
+
event: 'Issue.create',
|
|
179
|
+
config: {},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await dispatch({
|
|
183
|
+
provider: 'linear',
|
|
184
|
+
workspaceId,
|
|
185
|
+
source: 'Linear_Acme',
|
|
186
|
+
event: 'Issue.create',
|
|
187
|
+
deliveryId,
|
|
188
|
+
payload,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
projectId: subscription.projectId,
|
|
195
|
+
definitionId: subscription.workflowDefinitionId,
|
|
196
|
+
triggerPayload: {
|
|
197
|
+
provider: 'linear',
|
|
198
|
+
source: 'Linear_Acme',
|
|
199
|
+
event: 'Issue.create',
|
|
200
|
+
deliveryId,
|
|
201
|
+
data: payload,
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('dispatches AgentSessionEvent deliveries only to the matching app-user filter', async () => {
|
|
208
|
+
const workspaceId = crypto.randomUUID();
|
|
209
|
+
const payload = {
|
|
210
|
+
action: 'created',
|
|
211
|
+
type: 'AgentSessionEvent',
|
|
212
|
+
organizationId: 'linear-org-id',
|
|
213
|
+
appUserId: 'app-user-1',
|
|
214
|
+
webhookTimestamp: Date.now(),
|
|
215
|
+
agentSession: {id: 'session-id', commentId: 'comment-id'},
|
|
216
|
+
};
|
|
217
|
+
const matching = await triggerSubscriptionFactory.create({
|
|
218
|
+
workspaceId,
|
|
219
|
+
source: 'Linear_Acme',
|
|
220
|
+
event: 'agentSession.created',
|
|
221
|
+
config: {filter: 'event.appUserId == "app-user-1"'},
|
|
222
|
+
});
|
|
223
|
+
await triggerSubscriptionFactory.create({
|
|
224
|
+
workspaceId,
|
|
225
|
+
source: 'Linear_Acme',
|
|
226
|
+
event: 'agentSession.created',
|
|
227
|
+
config: {filter: 'event.appUserId == "other-app-user"'},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await dispatch({
|
|
231
|
+
provider: 'linear',
|
|
232
|
+
workspaceId,
|
|
233
|
+
source: 'Linear_Acme',
|
|
234
|
+
event: 'agentSession.created',
|
|
235
|
+
payload,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
240
|
+
expect.objectContaining({
|
|
241
|
+
projectId: matching.projectId,
|
|
242
|
+
triggerPayload: expect.objectContaining({data: payload}),
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('routes webhook events only when workspace, source, and received event match', async () => {
|
|
248
|
+
const workspaceId = crypto.randomUUID();
|
|
249
|
+
const matching = await triggerSubscriptionFactory.create({
|
|
250
|
+
workspaceId,
|
|
251
|
+
source: 'stripe_prod',
|
|
252
|
+
event: 'received',
|
|
253
|
+
config: {},
|
|
254
|
+
});
|
|
255
|
+
await triggerSubscriptionFactory.create({
|
|
256
|
+
workspaceId: crypto.randomUUID(),
|
|
257
|
+
source: 'stripe_prod',
|
|
258
|
+
event: 'received',
|
|
259
|
+
config: {},
|
|
260
|
+
});
|
|
261
|
+
await triggerSubscriptionFactory.create({
|
|
262
|
+
workspaceId,
|
|
263
|
+
source: 'stripe_stage',
|
|
264
|
+
event: 'received',
|
|
265
|
+
config: {},
|
|
266
|
+
});
|
|
267
|
+
await triggerSubscriptionFactory.create({
|
|
268
|
+
workspaceId,
|
|
269
|
+
source: 'stripe_prod',
|
|
270
|
+
event: 'ping',
|
|
271
|
+
config: {},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await dispatch({
|
|
275
|
+
provider: 'webhook',
|
|
276
|
+
workspaceId,
|
|
277
|
+
source: 'stripe_prod',
|
|
278
|
+
event: 'received',
|
|
279
|
+
payload: {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: {'x-stripe-signature': 'sig_123'},
|
|
282
|
+
query: {mode: 'live'},
|
|
283
|
+
body: {payment_id: 'pay_123'},
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
288
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
289
|
+
expect.objectContaining({
|
|
290
|
+
projectId: matching.projectId,
|
|
291
|
+
definitionId: matching.workflowDefinitionId,
|
|
292
|
+
triggerPayload: expect.objectContaining({
|
|
293
|
+
provider: 'webhook',
|
|
294
|
+
source: 'stripe_prod',
|
|
295
|
+
event: 'received',
|
|
296
|
+
data: {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: {'x-stripe-signature': 'sig_123'},
|
|
299
|
+
query: {mode: 'live'},
|
|
300
|
+
body: {payment_id: 'pay_123'},
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('passes triggerIdempotencyKey = subscription.id:eventRef to runWorkflow', async () => {
|
|
308
|
+
const workspaceId = crypto.randomUUID();
|
|
309
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
310
|
+
workspaceId,
|
|
311
|
+
source: 'github',
|
|
312
|
+
event: 'push',
|
|
313
|
+
config: {},
|
|
314
|
+
});
|
|
315
|
+
const eventRef = crypto.randomUUID();
|
|
316
|
+
|
|
317
|
+
await dispatch({workspaceId, eventRef});
|
|
318
|
+
|
|
319
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
320
|
+
expect.objectContaining({triggerIdempotencyKey: `${subscription.id}:${eventRef}`}),
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('forwards subscription.config.with as inputs to runWorkflow', async () => {
|
|
325
|
+
const workspaceId = crypto.randomUUID();
|
|
326
|
+
await triggerSubscriptionFactory.create({
|
|
327
|
+
workspaceId,
|
|
328
|
+
source: 'github',
|
|
329
|
+
event: 'push',
|
|
330
|
+
config: {with: {env: 'staging'}},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await dispatch({workspaceId});
|
|
334
|
+
|
|
335
|
+
expect(runWorkflow).toHaveBeenCalledWith(expect.objectContaining({inputs: {env: 'staging'}}));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('runs only subscriptions whose trigger filter matches the payload', async () => {
|
|
339
|
+
const workspaceId = crypto.randomUUID();
|
|
340
|
+
const matching = await triggerSubscriptionFactory.create({
|
|
341
|
+
workspaceId,
|
|
342
|
+
source: 'github',
|
|
343
|
+
event: 'push',
|
|
344
|
+
config: {filter: 'event.repository.full_name == "shipfox/platform"'},
|
|
345
|
+
});
|
|
346
|
+
await triggerSubscriptionFactory.create({
|
|
347
|
+
workspaceId,
|
|
348
|
+
source: 'github',
|
|
349
|
+
event: 'push',
|
|
350
|
+
config: {filter: 'event.repository.full_name == "shipfox/docs"'},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await dispatch({workspaceId, payload: {repository: {full_name: 'shipfox/platform'}}});
|
|
354
|
+
|
|
355
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
357
|
+
expect.objectContaining({projectId: matching.projectId}),
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('records a discarded event when source subscriptions are filtered out', async () => {
|
|
362
|
+
const workspaceId = crypto.randomUUID();
|
|
363
|
+
const eventRef = crypto.randomUUID();
|
|
364
|
+
await triggerSubscriptionFactory.create({
|
|
365
|
+
workspaceId,
|
|
366
|
+
source: 'github',
|
|
367
|
+
event: 'push',
|
|
368
|
+
config: {filter: 'event.ref == "refs/heads/main"'},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await dispatch({workspaceId, eventRef, payload: {ref: 'refs/heads/feature'}});
|
|
372
|
+
|
|
373
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
374
|
+
const event = await receivedEvent(eventRef);
|
|
375
|
+
if (!event) throw new Error('received event not found');
|
|
376
|
+
expect(event.outcome).toBe('discarded');
|
|
377
|
+
expect(event.matchedCount).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('records a filter-error decision when a trigger filter is invalid', async () => {
|
|
381
|
+
const workspaceId = crypto.randomUUID();
|
|
382
|
+
const eventRef = crypto.randomUUID();
|
|
383
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
384
|
+
workspaceId,
|
|
385
|
+
source: 'github',
|
|
386
|
+
event: 'push',
|
|
387
|
+
config: {filter: 'event.ref =='},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await dispatch({workspaceId, eventRef, payload: {ref: 'refs/heads/main'}});
|
|
391
|
+
|
|
392
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
393
|
+
const event = await receivedEvent(eventRef);
|
|
394
|
+
if (!event) throw new Error('received event not found');
|
|
395
|
+
expect(event.outcome).toBe('errored');
|
|
396
|
+
expect(event.matchedCount).toBe(1);
|
|
397
|
+
const decisions = await decisionsForEvent(event.id);
|
|
398
|
+
expect(decisions).toHaveLength(1);
|
|
399
|
+
expect(decisions[0]).toMatchObject({
|
|
400
|
+
subscriptionId: subscription.id,
|
|
401
|
+
decision: 'filter-error',
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('records a filter-error decision when a stored trigger filter is blank', async () => {
|
|
406
|
+
const workspaceId = crypto.randomUUID();
|
|
407
|
+
const eventRef = crypto.randomUUID();
|
|
408
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
409
|
+
workspaceId,
|
|
410
|
+
source: 'github',
|
|
411
|
+
event: 'push',
|
|
412
|
+
config: {filter: ' '},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await dispatch({workspaceId, eventRef, payload: {ref: 'refs/heads/main'}});
|
|
416
|
+
|
|
417
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
418
|
+
const event = await receivedEvent(eventRef);
|
|
419
|
+
if (!event) throw new Error('received event not found');
|
|
420
|
+
expect(event.outcome).toBe('errored');
|
|
421
|
+
expect(event.matchedCount).toBe(1);
|
|
422
|
+
const decisions = await decisionsForEvent(event.id);
|
|
423
|
+
expect(decisions).toHaveLength(1);
|
|
424
|
+
expect(decisions[0]).toMatchObject({
|
|
425
|
+
subscriptionId: subscription.id,
|
|
426
|
+
decision: 'filter-error',
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('does not fire when no subscription matches the workspace, source and event', async () => {
|
|
431
|
+
const workspaceId = crypto.randomUUID();
|
|
432
|
+
await triggerSubscriptionFactory.create({
|
|
433
|
+
workspaceId,
|
|
434
|
+
source: 'github',
|
|
435
|
+
event: 'push',
|
|
436
|
+
config: {},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await dispatch({workspaceId, event: 'pull_request'});
|
|
440
|
+
|
|
441
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('routes listener-only matches without creating a workflow run', async () => {
|
|
445
|
+
const workspaceId = crypto.randomUUID();
|
|
446
|
+
const eventRef = crypto.randomUUID();
|
|
447
|
+
const jobId = crypto.randomUUID();
|
|
448
|
+
await jobListenerSubscriptionFactory.create({
|
|
449
|
+
workspaceId,
|
|
450
|
+
jobId,
|
|
451
|
+
source: 'github',
|
|
452
|
+
event: 'push',
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await dispatch({workspaceId, eventRef});
|
|
456
|
+
|
|
457
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
458
|
+
expect(deliverEventToListener).toHaveBeenCalledWith(expect.objectContaining({jobId}));
|
|
459
|
+
const event = await receivedEvent(eventRef);
|
|
460
|
+
if (!event) throw new Error('received event not found');
|
|
461
|
+
expect(event.outcome).toBe('routed');
|
|
462
|
+
expect(event.matchedCount).toBe(1);
|
|
463
|
+
const decisions = await decisionsForEvent(event.id);
|
|
464
|
+
expect(decisions).toHaveLength(1);
|
|
465
|
+
expect(decisions[0]).toMatchObject({
|
|
466
|
+
subscriptionKind: 'listener',
|
|
467
|
+
jobId,
|
|
468
|
+
decision: 'triggered',
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('treats listener replay conflicts as routed idempotent success', async () => {
|
|
473
|
+
const workspaceId = crypto.randomUUID();
|
|
474
|
+
const eventRef = crypto.randomUUID();
|
|
475
|
+
await jobListenerSubscriptionFactory.create({
|
|
476
|
+
workspaceId,
|
|
477
|
+
source: 'github',
|
|
478
|
+
event: 'push',
|
|
479
|
+
});
|
|
480
|
+
deliverEventToListener.mockResolvedValue({buffered: false, skipped: false});
|
|
481
|
+
|
|
482
|
+
await dispatch({workspaceId, eventRef});
|
|
483
|
+
await dispatch({workspaceId, eventRef});
|
|
484
|
+
|
|
485
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
486
|
+
expect(deliverEventToListener).toHaveBeenCalledTimes(2);
|
|
487
|
+
const events = await db()
|
|
488
|
+
.select()
|
|
489
|
+
.from(triggersReceivedEvents)
|
|
490
|
+
.where(eq(triggersReceivedEvents.eventRef, eventRef));
|
|
491
|
+
expect(events).toHaveLength(1);
|
|
492
|
+
expect(events[0]?.outcome).toBe('routed');
|
|
493
|
+
expect(events[0]?.matchedCount).toBe(1);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('does not route listener-only stale subscriptions skipped by workflows', async () => {
|
|
497
|
+
const workspaceId = crypto.randomUUID();
|
|
498
|
+
const eventRef = crypto.randomUUID();
|
|
499
|
+
await jobListenerSubscriptionFactory.create({
|
|
500
|
+
workspaceId,
|
|
501
|
+
source: 'github',
|
|
502
|
+
event: 'push',
|
|
503
|
+
});
|
|
504
|
+
deliverEventToListener.mockResolvedValue({buffered: false, skipped: true});
|
|
505
|
+
|
|
506
|
+
await dispatch({workspaceId, eventRef});
|
|
507
|
+
|
|
508
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
509
|
+
expect(deliverEventToListener).toHaveBeenCalledTimes(1);
|
|
510
|
+
const event = await receivedEvent(eventRef);
|
|
511
|
+
if (!event) throw new Error('received event not found');
|
|
512
|
+
expect(event.outcome).toBe('discarded');
|
|
513
|
+
expect(event.matchedCount).toBe(0);
|
|
514
|
+
expect(await decisionsForEvent(event.id)).toHaveLength(0);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('routes both definition and listener fan-outs for the same event', async () => {
|
|
518
|
+
const workspaceId = crypto.randomUUID();
|
|
519
|
+
const definitionSubscription = await triggerSubscriptionFactory.create({
|
|
520
|
+
workspaceId,
|
|
521
|
+
source: 'github',
|
|
522
|
+
event: 'push',
|
|
523
|
+
config: {},
|
|
524
|
+
});
|
|
525
|
+
const jobListenerSubscription = await jobListenerSubscriptionFactory.create({
|
|
526
|
+
workspaceId,
|
|
527
|
+
source: 'github',
|
|
528
|
+
event: 'push',
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await dispatch({workspaceId});
|
|
532
|
+
|
|
533
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
534
|
+
expect.objectContaining({definitionId: definitionSubscription.workflowDefinitionId}),
|
|
535
|
+
);
|
|
536
|
+
expect(deliverEventToListener).toHaveBeenCalledWith(
|
|
537
|
+
expect.objectContaining({jobId: jobListenerSubscription.jobId}),
|
|
538
|
+
);
|
|
539
|
+
const [event] = await db()
|
|
540
|
+
.select()
|
|
541
|
+
.from(triggersReceivedEvents)
|
|
542
|
+
.where(eq(triggersReceivedEvents.workspaceId, workspaceId));
|
|
543
|
+
if (!event) throw new Error('received event not found');
|
|
544
|
+
expect(event.matchedCount).toBe(2);
|
|
545
|
+
const decisions = await decisionsForEvent(event.id);
|
|
546
|
+
expect(decisions).toHaveLength(2);
|
|
547
|
+
expect(decisions.map((decision) => decision.subscriptionKind).sort()).toEqual([
|
|
548
|
+
'listener',
|
|
549
|
+
'trigger',
|
|
550
|
+
]);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('does not deliver listener-only events whose filter does not match', async () => {
|
|
554
|
+
const workspaceId = crypto.randomUUID();
|
|
555
|
+
const eventRef = crypto.randomUUID();
|
|
556
|
+
await jobListenerSubscriptionFactory.create({
|
|
557
|
+
workspaceId,
|
|
558
|
+
source: 'github',
|
|
559
|
+
event: 'push',
|
|
560
|
+
config: {
|
|
561
|
+
filter: 'event.ref == "refs/heads/main"',
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
await dispatch({workspaceId, eventRef, payload: {ref: 'refs/heads/feature'}});
|
|
566
|
+
|
|
567
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
568
|
+
expect(deliverEventToListener).not.toHaveBeenCalled();
|
|
569
|
+
const event = await receivedEvent(eventRef);
|
|
570
|
+
if (!event) throw new Error('received event not found');
|
|
571
|
+
expect(event.outcome).toBe('discarded');
|
|
572
|
+
expect(event.matchedCount).toBe(0);
|
|
573
|
+
expect(await decisionsForEvent(event.id)).toHaveLength(0);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('records a listener filter-error decision when listener filter evaluation fails', async () => {
|
|
577
|
+
const workspaceId = crypto.randomUUID();
|
|
578
|
+
const eventRef = crypto.randomUUID();
|
|
579
|
+
const subscription = await jobListenerSubscriptionFactory.create({
|
|
580
|
+
workspaceId,
|
|
581
|
+
source: 'github',
|
|
582
|
+
event: 'push',
|
|
583
|
+
config: {
|
|
584
|
+
filter: 'jobs.build.outputs.pr_number == 42',
|
|
585
|
+
filter_snapshot: {},
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
await dispatch({workspaceId, eventRef});
|
|
590
|
+
|
|
591
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
592
|
+
expect(deliverEventToListener).not.toHaveBeenCalled();
|
|
593
|
+
const event = await receivedEvent(eventRef);
|
|
594
|
+
if (!event) throw new Error('received event not found');
|
|
595
|
+
expect(event.outcome).toBe('errored');
|
|
596
|
+
expect(event.matchedCount).toBe(1);
|
|
597
|
+
const decisions = await decisionsForEvent(event.id);
|
|
598
|
+
expect(decisions).toHaveLength(1);
|
|
599
|
+
expect(decisions[0]).toMatchObject({
|
|
600
|
+
subscriptionKind: 'listener',
|
|
601
|
+
subscriptionId: subscription.id,
|
|
602
|
+
workflowRunId: subscription.workflowRunId,
|
|
603
|
+
jobId: subscription.jobId,
|
|
604
|
+
matcherKind: 'on',
|
|
605
|
+
matcherOrdinal: subscription.matcherOrdinal,
|
|
606
|
+
decision: 'filter-error',
|
|
607
|
+
reason: 'Listener filter evaluation failed',
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('records a listener dispatch-error decision when listener delivery throws', async () => {
|
|
612
|
+
const workspaceId = crypto.randomUUID();
|
|
613
|
+
const eventRef = crypto.randomUUID();
|
|
614
|
+
const subscription = await jobListenerSubscriptionFactory.create({
|
|
615
|
+
workspaceId,
|
|
616
|
+
source: 'github',
|
|
617
|
+
event: 'push',
|
|
618
|
+
});
|
|
619
|
+
deliverEventToListener.mockRejectedValue(new Error('workflow db down'));
|
|
620
|
+
|
|
621
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('workflow db down');
|
|
622
|
+
|
|
623
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
624
|
+
const event = await receivedEvent(eventRef);
|
|
625
|
+
if (!event) throw new Error('received event not found');
|
|
626
|
+
expect(event.outcome).toBe('failed');
|
|
627
|
+
expect(event.matchedCount).toBe(1);
|
|
628
|
+
const decisions = await decisionsForEvent(event.id);
|
|
629
|
+
expect(decisions).toHaveLength(1);
|
|
630
|
+
expect(decisions[0]).toMatchObject({
|
|
631
|
+
subscriptionKind: 'listener',
|
|
632
|
+
subscriptionId: subscription.id,
|
|
633
|
+
decision: 'dispatch-error',
|
|
634
|
+
reason: 'workflow db down',
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
describe('dispatchIntegrationEvent trigger history', () => {
|
|
640
|
+
beforeEach(() => {
|
|
641
|
+
runWorkflow.mockReset();
|
|
642
|
+
deliverEventToListener.mockReset();
|
|
643
|
+
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Build and test'});
|
|
644
|
+
deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('records a discarded event when no subscription matches', async () => {
|
|
648
|
+
const workspaceId = crypto.randomUUID();
|
|
649
|
+
const eventRef = crypto.randomUUID();
|
|
650
|
+
|
|
651
|
+
await dispatch({workspaceId, event: 'pull_request', eventRef});
|
|
652
|
+
|
|
653
|
+
const event = await receivedEvent(eventRef);
|
|
654
|
+
if (!event) throw new Error('received event not found');
|
|
655
|
+
expect(event.origin).toBe('integration');
|
|
656
|
+
expect(event.outcome).toBe('discarded');
|
|
657
|
+
expect(event.matchedCount).toBe(0);
|
|
658
|
+
expect(event.processedAt).toBeInstanceOf(Date);
|
|
659
|
+
expect(await decisionsForEvent(event.id)).toHaveLength(0);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('records a routed event with a triggered decision per matched subscription', async () => {
|
|
663
|
+
const workspaceId = crypto.randomUUID();
|
|
664
|
+
const eventRef = crypto.randomUUID();
|
|
665
|
+
const subA = await triggerSubscriptionFactory.create({
|
|
666
|
+
workspaceId,
|
|
667
|
+
source: 'github',
|
|
668
|
+
event: 'push',
|
|
669
|
+
config: {},
|
|
670
|
+
});
|
|
671
|
+
const subB = await triggerSubscriptionFactory.create({
|
|
672
|
+
workspaceId,
|
|
673
|
+
source: 'github',
|
|
674
|
+
event: 'push',
|
|
675
|
+
config: {},
|
|
676
|
+
});
|
|
677
|
+
const runs: {id: string; name: string}[] = [];
|
|
678
|
+
runWorkflow.mockImplementation(() => {
|
|
679
|
+
const run = {id: crypto.randomUUID(), name: 'Build and test'};
|
|
680
|
+
runs.push(run);
|
|
681
|
+
return run;
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
await dispatch({workspaceId, eventRef});
|
|
685
|
+
|
|
686
|
+
const event = await receivedEvent(eventRef);
|
|
687
|
+
if (!event) throw new Error('received event not found');
|
|
688
|
+
expect(event.outcome).toBe('routed');
|
|
689
|
+
expect(event.matchedCount).toBe(2);
|
|
690
|
+
expect(event.processedAt).toBeInstanceOf(Date);
|
|
691
|
+
const decisions = await decisionsForEvent(event.id);
|
|
692
|
+
expect(decisions).toHaveLength(2);
|
|
693
|
+
expect(decisions.every((d) => d.decision === 'triggered')).toBe(true);
|
|
694
|
+
expect(decisions.map((d) => d.subscriptionId).sort()).toEqual([subA.id, subB.id].sort());
|
|
695
|
+
expect(decisions.map((d) => d.runId).sort()).toEqual(runs.map((r) => r.id).sort());
|
|
696
|
+
expect(decisions.every((d) => d.runName === 'Build and test')).toBe(true);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test('continues the fan-out past a transient error, records a failed event, and re-throws', async () => {
|
|
700
|
+
const workspaceId = crypto.randomUUID();
|
|
701
|
+
const eventRef = crypto.randomUUID();
|
|
702
|
+
await triggerSubscriptionFactory.create({
|
|
703
|
+
workspaceId,
|
|
704
|
+
source: 'github',
|
|
705
|
+
event: 'push',
|
|
706
|
+
config: {},
|
|
707
|
+
});
|
|
708
|
+
await triggerSubscriptionFactory.create({
|
|
709
|
+
workspaceId,
|
|
710
|
+
source: 'github',
|
|
711
|
+
event: 'push',
|
|
712
|
+
config: {},
|
|
713
|
+
});
|
|
714
|
+
runWorkflow.mockRejectedValue(new Error('runWorkflow boom'));
|
|
715
|
+
|
|
716
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('runWorkflow boom');
|
|
717
|
+
|
|
718
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
719
|
+
const event = await receivedEvent(eventRef);
|
|
720
|
+
if (!event) throw new Error('received event not found');
|
|
721
|
+
expect(event.outcome).toBe('failed');
|
|
722
|
+
expect(event.matchedCount).toBe(2);
|
|
723
|
+
expect(event.processedAt).toBeNull();
|
|
724
|
+
const decisions = await decisionsForEvent(event.id);
|
|
725
|
+
expect(decisions).toHaveLength(2);
|
|
726
|
+
expect(decisions.every((d) => d.decision === 'dispatch-error')).toBe(true);
|
|
727
|
+
expect(decisions.every((d) => d.reason?.includes('runWorkflow boom'))).toBe(true);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test('re-throws the first transient error, not a later one', async () => {
|
|
731
|
+
const workspaceId = crypto.randomUUID();
|
|
732
|
+
const eventRef = crypto.randomUUID();
|
|
733
|
+
await triggerSubscriptionFactory.create({
|
|
734
|
+
workspaceId,
|
|
735
|
+
source: 'github',
|
|
736
|
+
event: 'push',
|
|
737
|
+
config: {},
|
|
738
|
+
});
|
|
739
|
+
await triggerSubscriptionFactory.create({
|
|
740
|
+
workspaceId,
|
|
741
|
+
source: 'github',
|
|
742
|
+
event: 'push',
|
|
743
|
+
config: {},
|
|
744
|
+
});
|
|
745
|
+
let attempt = 0;
|
|
746
|
+
runWorkflow.mockImplementation(() => {
|
|
747
|
+
attempt += 1;
|
|
748
|
+
throw new Error(attempt === 1 ? 'first transient' : 'second transient');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('first transient');
|
|
752
|
+
|
|
753
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test('treats a thrown undefined as a transient failure and re-throws it', async () => {
|
|
757
|
+
const workspaceId = crypto.randomUUID();
|
|
758
|
+
const eventRef = crypto.randomUUID();
|
|
759
|
+
await triggerSubscriptionFactory.create({
|
|
760
|
+
workspaceId,
|
|
761
|
+
source: 'github',
|
|
762
|
+
event: 'push',
|
|
763
|
+
config: {},
|
|
764
|
+
});
|
|
765
|
+
runWorkflow.mockRejectedValue(undefined);
|
|
766
|
+
|
|
767
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toBeUndefined();
|
|
768
|
+
|
|
769
|
+
const event = await receivedEvent(eventRef);
|
|
770
|
+
if (!event) throw new Error('received event not found');
|
|
771
|
+
expect(event.outcome).toBe('failed');
|
|
772
|
+
expect(event.processedAt).toBeNull();
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test('runs every sibling and routes when one subscription is permanently broken', async () => {
|
|
776
|
+
const workspaceId = crypto.randomUUID();
|
|
777
|
+
const eventRef = crypto.randomUUID();
|
|
778
|
+
const poison = await triggerSubscriptionFactory.create({
|
|
779
|
+
workspaceId,
|
|
780
|
+
source: 'github',
|
|
781
|
+
event: 'push',
|
|
782
|
+
config: {},
|
|
783
|
+
});
|
|
784
|
+
const healthy = await triggerSubscriptionFactory.create({
|
|
785
|
+
workspaceId,
|
|
786
|
+
source: 'github',
|
|
787
|
+
event: 'push',
|
|
788
|
+
config: {},
|
|
789
|
+
});
|
|
790
|
+
const run = {id: crypto.randomUUID(), name: 'Build and test'};
|
|
791
|
+
runWorkflow.mockImplementation(({projectId}: {projectId: string}) => {
|
|
792
|
+
if (projectId === poison.projectId) throw new DefinitionNotFoundError('def-gone');
|
|
793
|
+
return run;
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
await dispatch({workspaceId, eventRef});
|
|
797
|
+
|
|
798
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
799
|
+
const event = await receivedEvent(eventRef);
|
|
800
|
+
if (!event) throw new Error('received event not found');
|
|
801
|
+
expect(event.outcome).toBe('routed');
|
|
802
|
+
expect(event.matchedCount).toBe(2);
|
|
803
|
+
expect(event.processedAt).toBeInstanceOf(Date);
|
|
804
|
+
const decisions = await decisionsForEvent(event.id);
|
|
805
|
+
const errored = decisions.find((d) => d.subscriptionId === poison.id);
|
|
806
|
+
const triggered = decisions.find((d) => d.subscriptionId === healthy.id);
|
|
807
|
+
expect(errored?.decision).toBe('dispatch-error');
|
|
808
|
+
expect(errored?.reason).toContain('Definition not found');
|
|
809
|
+
expect(triggered?.decision).toBe('triggered');
|
|
810
|
+
expect(triggered?.runId).toBe(run.id);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test('marks the event errored when every subscription errors permanently', async () => {
|
|
814
|
+
const workspaceId = crypto.randomUUID();
|
|
815
|
+
const eventRef = crypto.randomUUID();
|
|
816
|
+
await triggerSubscriptionFactory.create({
|
|
817
|
+
workspaceId,
|
|
818
|
+
source: 'github',
|
|
819
|
+
event: 'push',
|
|
820
|
+
config: {},
|
|
821
|
+
});
|
|
822
|
+
await triggerSubscriptionFactory.create({
|
|
823
|
+
workspaceId,
|
|
824
|
+
source: 'github',
|
|
825
|
+
event: 'push',
|
|
826
|
+
config: {},
|
|
827
|
+
});
|
|
828
|
+
runWorkflow.mockImplementation(() => {
|
|
829
|
+
throw new ProjectMismatchError('proj-a', 'proj-b');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await dispatch({workspaceId, eventRef});
|
|
833
|
+
|
|
834
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
835
|
+
const event = await receivedEvent(eventRef);
|
|
836
|
+
if (!event) throw new Error('received event not found');
|
|
837
|
+
expect(event.outcome).toBe('errored');
|
|
838
|
+
expect(event.matchedCount).toBe(2);
|
|
839
|
+
expect(event.processedAt).toBeInstanceOf(Date);
|
|
840
|
+
const decisions = await decisionsForEvent(event.id);
|
|
841
|
+
expect(decisions).toHaveLength(2);
|
|
842
|
+
expect(decisions.every((d) => d.decision === 'dispatch-error')).toBe(true);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('records failed (not errored) when a permanent and a transient error mix in one attempt', async () => {
|
|
846
|
+
const workspaceId = crypto.randomUUID();
|
|
847
|
+
const eventRef = crypto.randomUUID();
|
|
848
|
+
const permanent = await triggerSubscriptionFactory.create({
|
|
849
|
+
workspaceId,
|
|
850
|
+
source: 'github',
|
|
851
|
+
event: 'push',
|
|
852
|
+
config: {},
|
|
853
|
+
});
|
|
854
|
+
await triggerSubscriptionFactory.create({
|
|
855
|
+
workspaceId,
|
|
856
|
+
source: 'github',
|
|
857
|
+
event: 'push',
|
|
858
|
+
config: {},
|
|
859
|
+
});
|
|
860
|
+
runWorkflow.mockImplementation(({projectId}: {projectId: string}) => {
|
|
861
|
+
if (projectId === permanent.projectId) throw new DefinitionNotFoundError('def-gone');
|
|
862
|
+
throw new Error('transient boom');
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('transient boom');
|
|
866
|
+
|
|
867
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
868
|
+
const event = await receivedEvent(eventRef);
|
|
869
|
+
if (!event) throw new Error('received event not found');
|
|
870
|
+
expect(event.outcome).toBe('failed');
|
|
871
|
+
expect(event.processedAt).toBeNull();
|
|
872
|
+
const decisions = await decisionsForEvent(event.id);
|
|
873
|
+
expect(decisions).toHaveLength(2);
|
|
874
|
+
expect(decisions.every((d) => d.decision === 'dispatch-error')).toBe(true);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test('promotes to routed across replay when a prior run survives a later definition deletion', async () => {
|
|
878
|
+
const workspaceId = crypto.randomUUID();
|
|
879
|
+
const eventRef = crypto.randomUUID();
|
|
880
|
+
const ranThenDeleted = await triggerSubscriptionFactory.create({
|
|
881
|
+
workspaceId,
|
|
882
|
+
source: 'github',
|
|
883
|
+
event: 'push',
|
|
884
|
+
config: {},
|
|
885
|
+
});
|
|
886
|
+
const transient = await triggerSubscriptionFactory.create({
|
|
887
|
+
workspaceId,
|
|
888
|
+
source: 'github',
|
|
889
|
+
event: 'push',
|
|
890
|
+
config: {},
|
|
891
|
+
});
|
|
892
|
+
const run = {id: crypto.randomUUID(), name: 'Build and test'};
|
|
893
|
+
|
|
894
|
+
runWorkflow.mockImplementation(({projectId}: {projectId: string}) => {
|
|
895
|
+
if (projectId === ranThenDeleted.projectId) return run;
|
|
896
|
+
throw new Error('transient boom');
|
|
897
|
+
});
|
|
898
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('transient boom');
|
|
899
|
+
|
|
900
|
+
// A later permanent failure must not downgrade a run recorded during a prior
|
|
901
|
+
// transiently failed attempt.
|
|
902
|
+
runWorkflow.mockImplementation(() => {
|
|
903
|
+
throw new DefinitionNotFoundError('def-gone');
|
|
904
|
+
});
|
|
905
|
+
await dispatch({workspaceId, eventRef});
|
|
906
|
+
|
|
907
|
+
const event = await receivedEvent(eventRef);
|
|
908
|
+
if (!event) throw new Error('received event not found');
|
|
909
|
+
expect(event.outcome).toBe('routed');
|
|
910
|
+
expect(event.processedAt).toBeInstanceOf(Date);
|
|
911
|
+
const decisions = await decisionsForEvent(event.id);
|
|
912
|
+
const survived = decisions.find((d) => d.subscriptionId === ranThenDeleted.id);
|
|
913
|
+
const stillBroken = decisions.find((d) => d.subscriptionId === transient.id);
|
|
914
|
+
expect(survived?.decision).toBe('triggered');
|
|
915
|
+
expect(survived?.runId).toBe(run.id);
|
|
916
|
+
expect(stillBroken?.decision).toBe('dispatch-error');
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test('replaying the same event does not duplicate rows and reuses the idempotency key', async () => {
|
|
920
|
+
const workspaceId = crypto.randomUUID();
|
|
921
|
+
const eventRef = crypto.randomUUID();
|
|
922
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
923
|
+
workspaceId,
|
|
924
|
+
source: 'github',
|
|
925
|
+
event: 'push',
|
|
926
|
+
config: {},
|
|
927
|
+
});
|
|
928
|
+
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Build and test'});
|
|
929
|
+
|
|
930
|
+
await dispatch({workspaceId, eventRef});
|
|
931
|
+
await dispatch({workspaceId, eventRef});
|
|
932
|
+
|
|
933
|
+
const events = await db()
|
|
934
|
+
.select()
|
|
935
|
+
.from(triggersReceivedEvents)
|
|
936
|
+
.where(eq(triggersReceivedEvents.eventRef, eventRef));
|
|
937
|
+
expect(events).toHaveLength(1);
|
|
938
|
+
const event = events[0];
|
|
939
|
+
if (!event) throw new Error('received event not found');
|
|
940
|
+
expect(await decisionsForEvent(event.id)).toHaveLength(1);
|
|
941
|
+
// `runWorkflow` is mocked here; run-row dedup depends on stable keys at its boundary.
|
|
942
|
+
const keys = runWorkflow.mock.calls.map(([params]) => params.triggerIdempotencyKey);
|
|
943
|
+
expect(keys).toEqual([`${subscription.id}:${eventRef}`, `${subscription.id}:${eventRef}`]);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test('records a triggered and dispatch-error decision for a mixed-outcome fan-out', async () => {
|
|
947
|
+
const workspaceId = crypto.randomUUID();
|
|
948
|
+
const eventRef = crypto.randomUUID();
|
|
949
|
+
await triggerSubscriptionFactory.create({
|
|
950
|
+
workspaceId,
|
|
951
|
+
source: 'github',
|
|
952
|
+
event: 'push',
|
|
953
|
+
config: {},
|
|
954
|
+
});
|
|
955
|
+
await triggerSubscriptionFactory.create({
|
|
956
|
+
workspaceId,
|
|
957
|
+
source: 'github',
|
|
958
|
+
event: 'push',
|
|
959
|
+
config: {},
|
|
960
|
+
});
|
|
961
|
+
const run = {id: crypto.randomUUID(), name: 'Build and test'};
|
|
962
|
+
runWorkflow.mockResolvedValueOnce(run).mockRejectedValueOnce(new Error('second boom'));
|
|
963
|
+
|
|
964
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('second boom');
|
|
965
|
+
|
|
966
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
967
|
+
const event = await receivedEvent(eventRef);
|
|
968
|
+
if (!event) throw new Error('received event not found');
|
|
969
|
+
expect(event.outcome).toBe('failed');
|
|
970
|
+
expect(event.matchedCount).toBe(2);
|
|
971
|
+
const decisions = await decisionsForEvent(event.id);
|
|
972
|
+
expect(decisions).toHaveLength(2);
|
|
973
|
+
const triggered = decisions.find((d) => d.decision === 'triggered');
|
|
974
|
+
const errored = decisions.find((d) => d.decision === 'dispatch-error');
|
|
975
|
+
expect(triggered?.runId).toBe(run.id);
|
|
976
|
+
expect(errored?.reason).toContain('second boom');
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test('converges a failed event to routed when a later replay succeeds', async () => {
|
|
980
|
+
const workspaceId = crypto.randomUUID();
|
|
981
|
+
const eventRef = crypto.randomUUID();
|
|
982
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
983
|
+
workspaceId,
|
|
984
|
+
source: 'github',
|
|
985
|
+
event: 'push',
|
|
986
|
+
config: {},
|
|
987
|
+
});
|
|
988
|
+
const run = {id: crypto.randomUUID(), name: 'Build and test'};
|
|
989
|
+
runWorkflow.mockRejectedValueOnce(new Error('transient boom')).mockResolvedValue(run);
|
|
990
|
+
|
|
991
|
+
await expect(dispatch({workspaceId, eventRef})).rejects.toThrow('transient boom');
|
|
992
|
+
await dispatch({workspaceId, eventRef});
|
|
993
|
+
|
|
994
|
+
const event = await receivedEvent(eventRef);
|
|
995
|
+
if (!event) throw new Error('received event not found');
|
|
996
|
+
expect(event.outcome).toBe('routed');
|
|
997
|
+
expect(event.matchedCount).toBe(1);
|
|
998
|
+
expect(event.processedAt).toBeInstanceOf(Date);
|
|
999
|
+
const decisions = await decisionsForEvent(event.id);
|
|
1000
|
+
expect(decisions).toHaveLength(1);
|
|
1001
|
+
expect(decisions[0]?.subscriptionId).toBe(subscription.id);
|
|
1002
|
+
expect(decisions[0]?.decision).toBe('triggered');
|
|
1003
|
+
expect(decisions[0]?.runId).toBe(run.id);
|
|
1004
|
+
expect(decisions[0]?.reason).toBeNull();
|
|
1005
|
+
});
|
|
1006
|
+
});
|