@studio-foundation/api 0.3.0-beta.1 → 0.3.0-beta.6

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 (62) hide show
  1. package/package.json +7 -4
  2. package/ARCHITECTURE.md +0 -52
  3. package/src/.gitkeep +0 -0
  4. package/src/api.ts +0 -8
  5. package/src/bootstrap.ts +0 -259
  6. package/src/event-bus.ts +0 -64
  7. package/src/index.ts +0 -58
  8. package/src/integration-runtime.ts +0 -180
  9. package/src/integration-store.ts +0 -125
  10. package/src/integrations/linear/failure-handler.ts +0 -93
  11. package/src/integrations/linear/webhook-handler.ts +0 -156
  12. package/src/integrations/registry.ts +0 -12
  13. package/src/integrations/types.ts +0 -37
  14. package/src/launcher.ts +0 -214
  15. package/src/logger.ts +0 -50
  16. package/src/plans.ts +0 -43
  17. package/src/routes/agents.ts +0 -131
  18. package/src/routes/config.ts +0 -154
  19. package/src/routes/contracts.ts +0 -205
  20. package/src/routes/pipelines.ts +0 -146
  21. package/src/routes/projects.ts +0 -237
  22. package/src/routes/runs.ts +0 -468
  23. package/src/routes/skills.ts +0 -136
  24. package/src/routes/tools.ts +0 -222
  25. package/src/routes/users.ts +0 -169
  26. package/src/routes/validate.ts +0 -196
  27. package/src/routes/webhooks.ts +0 -120
  28. package/src/server.ts +0 -155
  29. package/src/spawners/http-api-spawner.ts +0 -96
  30. package/src/user-store-pg.ts +0 -138
  31. package/src/user-store.ts +0 -125
  32. package/src/utils/repo-resolver.ts +0 -3
  33. package/src/webhook-dispatcher.ts +0 -142
  34. package/src/webhook-store.ts +0 -141
  35. package/tests/agents.test.ts +0 -164
  36. package/tests/cancel.test.ts +0 -120
  37. package/tests/config.test.ts +0 -196
  38. package/tests/contracts.test.ts +0 -302
  39. package/tests/event-bus.test.ts +0 -53
  40. package/tests/http-api-spawner.test.ts +0 -158
  41. package/tests/integration-runtime.test.ts +0 -199
  42. package/tests/integration-store.test.ts +0 -66
  43. package/tests/integrations/linear/failure-handler.test.ts +0 -113
  44. package/tests/integrations/linear/webhook-handler.test.ts +0 -191
  45. package/tests/launcher.test.ts +0 -380
  46. package/tests/linear-webhook.test.ts +0 -390
  47. package/tests/pipelines.test.ts +0 -166
  48. package/tests/projects.test.ts +0 -283
  49. package/tests/runs.test.ts +0 -379
  50. package/tests/server.test.ts +0 -208
  51. package/tests/skills.test.ts +0 -149
  52. package/tests/sse.test.ts +0 -129
  53. package/tests/tools.test.ts +0 -233
  54. package/tests/user-store.test.ts +0 -93
  55. package/tests/users.test.ts +0 -189
  56. package/tests/utils/repo-resolver.test.ts +0 -105
  57. package/tests/validate.test.ts +0 -233
  58. package/tests/webhook-dispatcher.test.ts +0 -214
  59. package/tests/webhook-store.test.ts +0 -98
  60. package/tests/webhooks.test.ts +0 -176
  61. package/tsconfig.json +0 -24
  62. package/vitest.config.ts +0 -7
@@ -1,233 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
- import { buildServer } from '../src/server.js';
5
- import { InMemoryRunStore } from '@studio-foundation/engine';
6
- import type { IntegrationRuntime } from '../src/integration-runtime.js';
7
- import type { IntegrationStore } from '../src/integration-store.js';
8
-
9
- const TMP = resolve('/tmp', `.studio-validate-test-${Date.now()}`);
10
-
11
- const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
12
- const nullIntegrationStore = {} as unknown as IntegrationStore;
13
-
14
- function makeServer() {
15
- return buildServer({
16
- store: new InMemoryRunStore(),
17
- launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
18
- configsDir: TMP,
19
- projectName: 'test-project',
20
- apiConfig: {},
21
- studioVersion: '0.0.0-test',
22
- maskedConfig: { providers: [] },
23
- integrationRuntime: nullIntegrationRuntime,
24
- integrationStore: nullIntegrationStore,
25
- });
26
- }
27
-
28
- beforeAll(() => {
29
- mkdirSync(resolve(TMP, 'agents'), { recursive: true });
30
- mkdirSync(resolve(TMP, 'contracts'), { recursive: true });
31
- mkdirSync(resolve(TMP, 'tools'), { recursive: true });
32
-
33
- writeFileSync(resolve(TMP, 'agents', 'analyst.agent.yaml'), 'name: analyst\nmodel: claude-sonnet-4-20250514\n');
34
- writeFileSync(resolve(TMP, 'contracts', 'brief-analysis.contract.yaml'), 'name: brief-analysis\nversion: 1\n');
35
- writeFileSync(resolve(TMP, 'tools', 'my-tool.tool.yaml'), 'name: my-tool\n');
36
- });
37
-
38
- afterAll(() => {
39
- rmSync(TMP, { recursive: true, force: true });
40
- });
41
-
42
- describe('POST /api/validate', () => {
43
- it('returns valid=true for a pipeline with existing agent and contract', async () => {
44
- const server = makeServer();
45
- const res = await server.inject({
46
- method: 'POST',
47
- url: '/api/validate',
48
- payload: {
49
- type: 'pipeline',
50
- name: 'feature-builder',
51
- content: 'stages:\n - name: analysis\n agent: analyst\n contract: brief-analysis\n',
52
- },
53
- });
54
- expect(res.statusCode).toBe(200);
55
- const body = res.json() as { valid: boolean; errors: string[] };
56
- expect(body.valid).toBe(true);
57
- expect(body.errors).toHaveLength(0);
58
- });
59
-
60
- it('returns errors for a pipeline referencing a missing agent', async () => {
61
- const server = makeServer();
62
- const res = await server.inject({
63
- method: 'POST',
64
- url: '/api/validate',
65
- payload: {
66
- type: 'pipeline',
67
- name: 'bad-pipeline',
68
- content: 'stages:\n - name: s1\n agent: missing-agent\n contract: brief-analysis\n',
69
- },
70
- });
71
- expect(res.statusCode).toBe(200);
72
- const body = res.json() as { valid: boolean; errors: string[] };
73
- expect(body.valid).toBe(false);
74
- expect(body.errors).toContain("Agent 'missing-agent' not found");
75
- });
76
-
77
- it('returns errors for a pipeline referencing a missing contract', async () => {
78
- const server = makeServer();
79
- const res = await server.inject({
80
- method: 'POST',
81
- url: '/api/validate',
82
- payload: {
83
- type: 'pipeline',
84
- name: 'bad-pipeline',
85
- content: 'stages:\n - name: s1\n agent: analyst\n contract: missing-contract\n',
86
- },
87
- });
88
- expect(res.statusCode).toBe(200);
89
- const body = res.json() as { valid: boolean; errors: string[] };
90
- expect(body.valid).toBe(false);
91
- expect(body.errors).toContain("Contract 'missing-contract' not found");
92
- });
93
-
94
- it('validates group stages recursively', async () => {
95
- const server = makeServer();
96
- const res = await server.inject({
97
- method: 'POST',
98
- url: '/api/validate',
99
- payload: {
100
- type: 'pipeline',
101
- name: 'grouped',
102
- content: `stages:\n - group: impl\n max_iterations: 3\n stages:\n - name: code\n agent: analyst\n contract: missing-contract\n`,
103
- },
104
- });
105
- expect(res.statusCode).toBe(200);
106
- const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
107
- expect(valid).toBe(false);
108
- expect(errors).toContain("Contract 'missing-contract' not found");
109
- });
110
-
111
- it('returns errors for agent with unknown tool', async () => {
112
- const server = makeServer();
113
- const res = await server.inject({
114
- method: 'POST',
115
- url: '/api/validate',
116
- payload: {
117
- type: 'agent',
118
- name: 'coder',
119
- content: 'name: coder\ntools:\n - repo_manager-write_file\n - ghost-tool\n',
120
- },
121
- });
122
- expect(res.statusCode).toBe(200);
123
- const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
124
- expect(valid).toBe(false);
125
- expect(errors).toContain("Tool 'ghost-tool' not found");
126
- });
127
-
128
- it('accepts agent with builtin tools', async () => {
129
- const server = makeServer();
130
- const res = await server.inject({
131
- method: 'POST',
132
- url: '/api/validate',
133
- payload: {
134
- type: 'agent',
135
- name: 'coder',
136
- content: 'name: coder\ntools:\n - repo_manager-write_file\n - shell-run_command\n',
137
- },
138
- });
139
- const { valid } = res.json() as { valid: boolean; errors: string[] };
140
- expect(valid).toBe(true);
141
- });
142
-
143
- it('accepts agent with custom tools (exact plugin name)', async () => {
144
- const server = makeServer();
145
- const res = await server.inject({
146
- method: 'POST',
147
- url: '/api/validate',
148
- payload: {
149
- type: 'agent',
150
- name: 'coder',
151
- content: 'name: coder\ntools:\n - my-tool\n',
152
- },
153
- });
154
- const { valid } = res.json() as { valid: boolean; errors: string[] };
155
- expect(valid).toBe(true);
156
- });
157
-
158
- it('accepts agent with custom tool actions (plugin-action naming)', async () => {
159
- const server = makeServer();
160
- const res = await server.inject({
161
- method: 'POST',
162
- url: '/api/validate',
163
- payload: {
164
- type: 'agent',
165
- name: 'coder',
166
- content: 'name: coder\ntools:\n - my-tool-run\n',
167
- },
168
- });
169
- const { valid } = res.json() as { valid: boolean; errors: string[] };
170
- expect(valid).toBe(true);
171
- });
172
-
173
- it('returns parse error for invalid YAML', async () => {
174
- const server = makeServer();
175
- const res = await server.inject({
176
- method: 'POST',
177
- url: '/api/validate',
178
- payload: { type: 'contract', name: 'x', content: 'key: [unclosed' },
179
- });
180
- const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
181
- expect(valid).toBe(false);
182
- expect(errors[0]).toMatch(/YAML parse error/);
183
- });
184
-
185
- it('validates skill as valid when non-empty', async () => {
186
- const server = makeServer();
187
- const res = await server.inject({
188
- method: 'POST',
189
- url: '/api/validate',
190
- payload: { type: 'skill', name: 'my-skill', content: '# My skill\nDo this thing.' },
191
- });
192
- const { valid } = res.json() as { valid: boolean; errors: string[] };
193
- expect(valid).toBe(true);
194
- });
195
-
196
- it('validates empty skill as invalid', async () => {
197
- const server = makeServer();
198
- const res = await server.inject({
199
- method: 'POST',
200
- url: '/api/validate',
201
- payload: { type: 'skill', name: 'my-skill', content: ' ' },
202
- });
203
- const { valid } = res.json() as { valid: boolean; errors: string[] };
204
- expect(valid).toBe(false);
205
- });
206
-
207
- it('returns error when pipeline has no stages array', async () => {
208
- const server = makeServer();
209
- const res = await server.inject({
210
- method: 'POST',
211
- url: '/api/validate',
212
- payload: { type: 'pipeline', name: 'p', content: 'name: my-pipeline\n' },
213
- });
214
- const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
215
- expect(valid).toBe(false);
216
- expect(errors).toContain('Pipeline must have a "stages" array');
217
- });
218
-
219
- it('returns valid=true for a well-formed contract', async () => {
220
- const server = makeServer();
221
- const res = await server.inject({
222
- method: 'POST',
223
- url: '/api/validate',
224
- payload: {
225
- type: 'contract',
226
- name: 'c',
227
- content: 'name: my-contract\nversion: 1\nschema:\n required_fields:\n - summary\n',
228
- },
229
- });
230
- const { valid } = res.json() as { valid: boolean; errors: string[] };
231
- expect(valid).toBe(true);
232
- });
233
- });
@@ -1,214 +0,0 @@
1
- import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { resolve } from 'node:path';
3
- import { mkdirSync } from 'node:fs';
4
- import { WebhookStore } from '../src/webhook-store.js';
5
- import { WebhookDispatcher } from '../src/webhook-dispatcher.js';
6
-
7
- type FetchCall = { url: string; options: RequestInit };
8
-
9
- function makeStore(): { store: WebhookStore; cleanup: () => void } {
10
- const dir = resolve('/tmp', `.studio-dispatcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
11
- mkdirSync(dir, { recursive: true });
12
- const store = new WebhookStore(resolve(dir, 'runs.db'));
13
- return { store, cleanup: () => store.close() };
14
- }
15
-
16
- function makeFetch(status = 200): { fetch: typeof fetch; calls: FetchCall[] } {
17
- const calls: FetchCall[] = [];
18
- const mockFetch = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
19
- calls.push({ url: url.toString(), options: options ?? {} });
20
- return new Response('OK', { status });
21
- }) as unknown as typeof fetch;
22
- return { fetch: mockFetch, calls };
23
- }
24
-
25
- describe('WebhookDispatcher', () => {
26
- let store: WebhookStore;
27
- let cleanup: () => void;
28
-
29
- beforeEach(() => {
30
- ({ store, cleanup } = makeStore());
31
- });
32
-
33
- afterEach(() => {
34
- cleanup();
35
- });
36
-
37
- test('dispatches pipeline_complete to matching webhook', async () => {
38
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
39
- const { fetch, calls } = makeFetch();
40
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
41
-
42
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', {
43
- pipeline_name: 'feature-builder',
44
- run_id: 'run-1',
45
- status: 'success',
46
- duration_ms: 1000,
47
- total_tokens: 100,
48
- total_tool_calls: 2,
49
- });
50
-
51
- expect(calls).toHaveLength(1);
52
- expect(calls[0].url).toBe('https://hook.example.com/cb');
53
- });
54
-
55
- test('does not dispatch when event type does not match webhook subscription', async () => {
56
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_failed'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
57
- const { fetch, calls } = makeFetch();
58
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
59
-
60
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
61
-
62
- expect(calls).toHaveLength(0);
63
- });
64
-
65
- test('includes X-Studio-Event header', async () => {
66
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
67
- const { fetch, calls } = makeFetch();
68
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
69
-
70
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
71
-
72
- const headers = calls[0].options.headers as Record<string, string>;
73
- expect(headers['X-Studio-Event']).toBe('pipeline_complete');
74
- });
75
-
76
- test('includes X-Studio-Signature header when webhook has a secret', async () => {
77
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], secret: 'mysecret', status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
78
- const { fetch, calls } = makeFetch();
79
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
80
-
81
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
82
-
83
- const headers = calls[0].options.headers as Record<string, string>;
84
- expect(headers['X-Studio-Signature']).toMatch(/^sha256=[a-f0-9]{64}$/);
85
- });
86
-
87
- test('does not include X-Studio-Signature when no secret configured', async () => {
88
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
89
- const { fetch, calls } = makeFetch();
90
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
91
-
92
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
93
-
94
- const headers = calls[0].options.headers as Record<string, string>;
95
- expect(headers['X-Studio-Signature']).toBeUndefined();
96
- });
97
-
98
- test('payload contains run_id, event, ts fields', async () => {
99
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
100
- const { fetch, calls } = makeFetch();
101
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
102
-
103
- await dispatcher.handleBusEvent('run-42', 'pipeline_complete', { status: 'success' });
104
-
105
- const body = JSON.parse(calls[0].options.body as string);
106
- expect(body.run_id).toBe('run-42');
107
- expect(body.event).toBe('pipeline_complete');
108
- expect(body.ts).toBeDefined();
109
- });
110
-
111
- test('maps stage_complete with rejected status to stage_rejected webhook event', async () => {
112
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_rejected'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
113
- const { fetch, calls } = makeFetch();
114
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
115
-
116
- await dispatcher.handleBusEvent('run-1', 'stage_complete', { stage_name: 'qa', status: 'rejected' });
117
-
118
- expect(calls).toHaveLength(1);
119
- const body = JSON.parse(calls[0].options.body as string);
120
- expect(body.event).toBe('stage_rejected');
121
- });
122
-
123
- test('maps stage_complete with failed status to stage_failed webhook event', async () => {
124
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_failed'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
125
- const { fetch, calls } = makeFetch();
126
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
127
-
128
- await dispatcher.handleBusEvent('run-1', 'stage_complete', { stage_name: 'code', status: 'failed' });
129
-
130
- expect(calls).toHaveLength(1);
131
- const body = JSON.parse(calls[0].options.body as string);
132
- expect(body.event).toBe('stage_failed');
133
- });
134
-
135
- test('maps stage_complete with success status to stage_complete webhook event', async () => {
136
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
137
- const { fetch, calls } = makeFetch();
138
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
139
-
140
- await dispatcher.handleBusEvent('run-1', 'stage_complete', { stage_name: 'code', status: 'success' });
141
-
142
- expect(calls).toHaveLength(1);
143
- const body = JSON.parse(calls[0].options.body as string);
144
- expect(body.event).toBe('stage_complete');
145
- });
146
-
147
- test('does not dispatch to failed webhooks', async () => {
148
- store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'failed', created_at: '2026-01-01T00:00:00.000Z' });
149
- const { fetch, calls } = makeFetch();
150
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
151
-
152
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
153
-
154
- expect(calls).toHaveLength(0);
155
- });
156
-
157
- test('dispatches to multiple matching webhooks', async () => {
158
- store.saveWebhook({ id: 'wh-1', url: 'https://a.example.com/hook', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
159
- store.saveWebhook({ id: 'wh-2', url: 'https://b.example.com/hook', events: ['pipeline_complete', 'stage_failed'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
160
- const { fetch, calls } = makeFetch();
161
- const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
162
-
163
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
164
-
165
- expect(calls).toHaveLength(2);
166
- });
167
-
168
- test('retries on HTTP 5xx and marks webhook failed after 3 failed retries', async () => {
169
- vi.useFakeTimers();
170
- let callCount = 0;
171
- const failingFetch = vi.fn(async () => {
172
- callCount++;
173
- return new Response('Error', { status: 500 });
174
- }) as unknown as typeof fetch;
175
-
176
- const { store: s, cleanup: c } = makeStore();
177
- s.saveWebhook({ id: 'wh-fail', url: 'https://fail.example.com/hook', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
178
- const dispatcher = new WebhookDispatcher(s, 'test', failingFetch);
179
-
180
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
181
-
182
- // Advance through all retry delays: 30s + 5min + 15min
183
- await vi.advanceTimersByTimeAsync(30_001);
184
- await vi.advanceTimersByTimeAsync(5 * 60_001);
185
- await vi.advanceTimersByTimeAsync(15 * 60_001);
186
-
187
- expect(callCount).toBe(4); // 1 original + 3 retries
188
- expect(s.getWebhook('wh-fail')?.status).toBe('failed');
189
-
190
- c();
191
- vi.useRealTimers();
192
- });
193
-
194
- test('does not retry on successful delivery', async () => {
195
- vi.useFakeTimers();
196
- let callCount = 0;
197
- const successFetch = vi.fn(async () => {
198
- callCount++;
199
- return new Response('OK', { status: 200 });
200
- }) as unknown as typeof fetch;
201
-
202
- const { store: s, cleanup: c } = makeStore();
203
- s.saveWebhook({ id: 'wh-ok', url: 'https://ok.example.com/hook', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
204
- const dispatcher = new WebhookDispatcher(s, 'test', successFetch);
205
-
206
- await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
207
- await vi.runAllTimersAsync();
208
-
209
- expect(callCount).toBe(1);
210
-
211
- c();
212
- vi.useRealTimers();
213
- });
214
- });
@@ -1,98 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
- import { resolve } from 'node:path';
3
- import { mkdirSync } from 'node:fs';
4
- import { WebhookStore, type WebhookRegistration, type WebhookDelivery } from '../src/webhook-store.js';
5
-
6
- describe('WebhookStore', () => {
7
- let store: WebhookStore;
8
-
9
- beforeEach(() => {
10
- const dbDir = resolve('/tmp', `.studio-webhook-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
11
- mkdirSync(dbDir, { recursive: true });
12
- store = new WebhookStore(resolve(dbDir, 'runs.db'));
13
- });
14
-
15
- afterEach(() => {
16
- store.close();
17
- });
18
-
19
- test('saves and retrieves a webhook', () => {
20
- const webhook: WebhookRegistration = {
21
- id: 'wh-1',
22
- url: 'https://example.com/webhook',
23
- events: ['pipeline_complete', 'stage_failed'],
24
- secret: 'secret123',
25
- status: 'active',
26
- created_at: '2026-01-01T00:00:00.000Z',
27
- };
28
- store.saveWebhook(webhook);
29
- expect(store.getWebhook('wh-1')).toEqual(webhook);
30
- });
31
-
32
- test('returns null for unknown webhook', () => {
33
- expect(store.getWebhook('nonexistent')).toBeNull();
34
- });
35
-
36
- test('lists all webhooks', () => {
37
- store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
38
- store.saveWebhook({ id: 'wh-2', url: 'https://b.com', events: ['stage_failed'], status: 'active', created_at: '2026-01-02T00:00:00.000Z' });
39
- const webhooks = store.listWebhooks();
40
- expect(webhooks).toHaveLength(2);
41
- expect(webhooks.map(w => w.id)).toContain('wh-1');
42
- expect(webhooks.map(w => w.id)).toContain('wh-2');
43
- });
44
-
45
- test('returns empty list when no webhooks', () => {
46
- expect(store.listWebhooks()).toHaveLength(0);
47
- });
48
-
49
- test('deletes a webhook and returns true', () => {
50
- store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
51
- expect(store.deleteWebhook('wh-1')).toBe(true);
52
- expect(store.getWebhook('wh-1')).toBeNull();
53
- });
54
-
55
- test('deleteWebhook returns false for unknown id', () => {
56
- expect(store.deleteWebhook('does-not-exist')).toBe(false);
57
- });
58
-
59
- test('marks webhook as failed', () => {
60
- store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
61
- store.markWebhookFailed('wh-1');
62
- expect(store.getWebhook('wh-1')?.status).toBe('failed');
63
- });
64
-
65
- test('saves a delivery without error', () => {
66
- store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
67
- const delivery: WebhookDelivery = {
68
- id: 'del-1',
69
- webhook_id: 'wh-1',
70
- event: 'pipeline_complete',
71
- run_id: 'run-1',
72
- status: 'pending',
73
- attempt: 1,
74
- created_at: '2026-01-01T00:00:00.000Z',
75
- };
76
- expect(() => store.saveDelivery(delivery)).not.toThrow();
77
- });
78
-
79
- test('updates a delivery status without error', () => {
80
- store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
81
- store.saveDelivery({
82
- id: 'del-1',
83
- webhook_id: 'wh-1',
84
- event: 'pipeline_complete',
85
- run_id: 'run-1',
86
- status: 'pending',
87
- attempt: 1,
88
- created_at: '2026-01-01T00:00:00.000Z',
89
- });
90
- expect(() => store.updateDelivery('del-1', 'success')).not.toThrow();
91
- });
92
-
93
- test('persists events array as JSON', () => {
94
- store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: ['pipeline_start', 'pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
95
- const result = store.getWebhook('wh-1');
96
- expect(result?.events).toEqual(['pipeline_start', 'pipeline_complete']);
97
- });
98
- });