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

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,390 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { createHmac } from 'node:crypto';
3
- import { resolve } from 'node:path';
4
- import { mkdirSync } from 'node:fs';
5
- import { buildServer } from '../src/server.js';
6
- import { InMemoryRunStore } from '@studio-foundation/engine';
7
- import { WebhookStore } from '../src/webhook-store.js';
8
- import { IntegrationStore } from '../src/integration-store.js';
9
- import { IntegrationRuntime } from '../src/integration-runtime.js';
10
- import type { IntegrationPluginDef } from '@studio-foundation/contracts';
11
-
12
- const WEBHOOK_SECRET = 'test-whsec-abc123';
13
-
14
- const linearIntegration: IntegrationPluginDef = {
15
- name: 'linear',
16
- version: 1,
17
- webhook: {
18
- hmac: {
19
- header: 'linear-signature',
20
- secret_env: 'LINEAR_WEBHOOK_SECRET',
21
- },
22
- handler: 'linear-webhook',
23
- },
24
- on_failure: {
25
- handler: 'linear-failure',
26
- },
27
- };
28
-
29
- function sign(body: string, secret: string): string {
30
- return createHmac('sha256', secret).update(Buffer.from(body)).digest('hex');
31
- }
32
-
33
- function makeServer(opts: { withSecret?: boolean; withApiKey?: boolean; active?: boolean } = {}) {
34
- const dir = resolve('/tmp', `.studio-linear-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
35
- mkdirSync(dir, { recursive: true });
36
- const dbPath = resolve(dir, 'runs.db');
37
- const webhookStore = new WebhookStore(dbPath);
38
- const integrationStore = new IntegrationStore(dbPath);
39
-
40
- // Default to active so existing tests trigger launches as before
41
- integrationStore.patchConfig('linear', { active: opts.active ?? true });
42
-
43
- const launched: Array<{ pipeline: string; input: Record<string, unknown>; meta?: Record<string, unknown> }> = [];
44
- const launcher = {
45
- launch: vi.fn(async (cfg: { pipeline: string; input: Record<string, unknown>; meta?: Record<string, unknown>; runId: string }) => {
46
- launched.push({ pipeline: cfg.pipeline, input: cfg.input, meta: cfg.meta });
47
- return { run_id: cfg.runId };
48
- }),
49
- cancel: vi.fn(async () => {}),
50
- subscribe: vi.fn(() => () => {}),
51
- };
52
-
53
- const integrationRuntime = new IntegrationRuntime({
54
- integrations: [linearIntegration],
55
- store: integrationStore,
56
- launcher,
57
- configsDir: dir,
58
- apiConfig: { ...(opts.withApiKey ? { key: 'sk-studio-test' } : {}) },
59
- integrationConfigs: {
60
- linear: {
61
- ...(opts.withSecret ? { LINEAR_WEBHOOK_SECRET: WEBHOOK_SECRET } : {}),
62
- },
63
- },
64
- });
65
-
66
- const server = buildServer({
67
- store: new InMemoryRunStore(),
68
- launcher,
69
- configsDir: dir,
70
- projectName: 'test-project',
71
- apiConfig: {
72
- ...(opts.withApiKey ? { key: 'sk-studio-test' } : {}),
73
- },
74
- studioVersion: '0.0.0',
75
- maskedConfig: { providers: [] },
76
- webhookStore,
77
- integrationRuntime,
78
- integrationStore,
79
- });
80
-
81
- return { server, launched, launcher, integrationStore, cleanup: () => { webhookStore.close(); integrationStore.close(); } };
82
- }
83
-
84
- function inProgressPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
85
- return {
86
- type: 'Issue',
87
- action: 'update',
88
- data: {
89
- id: 'abc-123',
90
- identifier: 'STU-42',
91
- title: 'Add dark mode toggle',
92
- description: 'Users want a dark mode option in the settings.',
93
- state: { name: 'In Progress' },
94
- ...overrides,
95
- },
96
- };
97
- }
98
-
99
- describe('POST /api/integrations/linear/webhook — no secret configured', () => {
100
- let server: ReturnType<typeof makeServer>['server'];
101
- let launched: ReturnType<typeof makeServer>['launched'];
102
- let cleanup: () => void;
103
-
104
- beforeEach(() => {
105
- ({ server, launched, cleanup } = makeServer());
106
- });
107
-
108
- afterEach(() => {
109
- cleanup();
110
- });
111
-
112
- test('accepts "In Progress" transition and launches feature-builder', async () => {
113
- const payload = inProgressPayload();
114
- const res = await server.inject({
115
- method: 'POST',
116
- url: '/api/integrations/linear/webhook',
117
- headers: { 'content-type': 'application/json' },
118
- payload: JSON.stringify(payload),
119
- });
120
-
121
- expect(res.statusCode).toBe(202);
122
- const body = res.json<{ run_id: string; stream_url: string }>();
123
- expect(body.run_id).toBeDefined();
124
- expect(body.stream_url).toMatch(/\/api\/runs\/.+\/stream/);
125
-
126
- expect(launched).toHaveLength(1);
127
- expect(launched[0].pipeline).toBe('feature-builder');
128
- expect(launched[0].input['brief_summary']).toBe('STU-42 — Add dark mode toggle');
129
- expect(launched[0].meta?.['linear_issue_id']).toBe('abc-123');
130
- expect(launched[0].meta?.['linear_issue_identifier']).toBe('STU-42');
131
- expect(launched[0].meta?.['linear_issue_url']).toBe('https://linear.app/studioag/issue/STU-42');
132
- });
133
-
134
- test('ignores non-"In Progress" state transitions', async () => {
135
- const payload = inProgressPayload({ state: { name: 'Done' } });
136
- const res = await server.inject({
137
- method: 'POST',
138
- url: '/api/integrations/linear/webhook',
139
- headers: { 'content-type': 'application/json' },
140
- payload: JSON.stringify(payload),
141
- });
142
-
143
- expect(res.statusCode).toBe(200);
144
- const body = res.json<{ ignored: boolean; reason: string }>();
145
- expect(body.ignored).toBe(true);
146
- expect(body.reason).toContain('Done');
147
- expect(launched).toHaveLength(0);
148
- });
149
-
150
- test('ignores non-update actions', async () => {
151
- const payload = { ...inProgressPayload(), action: 'create' };
152
- const res = await server.inject({
153
- method: 'POST',
154
- url: '/api/integrations/linear/webhook',
155
- headers: { 'content-type': 'application/json' },
156
- payload: JSON.stringify(payload),
157
- });
158
-
159
- expect(res.statusCode).toBe(200);
160
- const body = res.json<{ ignored: boolean }>();
161
- expect(body.ignored).toBe(true);
162
- expect(launched).toHaveLength(0);
163
- });
164
-
165
- test('ignores non-Issue types', async () => {
166
- const payload = { ...inProgressPayload(), type: 'Comment' };
167
- const res = await server.inject({
168
- method: 'POST',
169
- url: '/api/integrations/linear/webhook',
170
- headers: { 'content-type': 'application/json' },
171
- payload: JSON.stringify(payload),
172
- });
173
-
174
- expect(res.statusCode).toBe(200);
175
- const body = res.json<{ ignored: boolean }>();
176
- expect(body.ignored).toBe(true);
177
- expect(launched).toHaveLength(0);
178
- });
179
-
180
- test('returns 400 for invalid JSON body', async () => {
181
- const res = await server.inject({
182
- method: 'POST',
183
- url: '/api/integrations/linear/webhook',
184
- headers: { 'content-type': 'application/json' },
185
- payload: 'not-json{',
186
- });
187
-
188
- expect(res.statusCode).toBe(400);
189
- });
190
- });
191
-
192
- describe('POST /api/integrations/linear/webhook — with HMAC secret', () => {
193
- let server: ReturnType<typeof makeServer>['server'];
194
- let launched: ReturnType<typeof makeServer>['launched'];
195
- let cleanup: () => void;
196
-
197
- beforeEach(() => {
198
- ({ server, launched, cleanup } = makeServer({ withSecret: true }));
199
- });
200
-
201
- afterEach(() => {
202
- cleanup();
203
- });
204
-
205
- test('accepts request with valid signature', async () => {
206
- const body = JSON.stringify(inProgressPayload());
207
- const sig = sign(body, WEBHOOK_SECRET);
208
-
209
- const res = await server.inject({
210
- method: 'POST',
211
- url: '/api/integrations/linear/webhook',
212
- headers: { 'content-type': 'application/json', 'linear-signature': sig },
213
- payload: body,
214
- });
215
-
216
- expect(res.statusCode).toBe(202);
217
- expect(launched).toHaveLength(1);
218
- });
219
-
220
- test('rejects request with invalid signature', async () => {
221
- const body = JSON.stringify(inProgressPayload());
222
-
223
- const res = await server.inject({
224
- method: 'POST',
225
- url: '/api/integrations/linear/webhook',
226
- headers: { 'content-type': 'application/json', 'linear-signature': 'deadbeef' },
227
- payload: body,
228
- });
229
-
230
- expect(res.statusCode).toBe(401);
231
- expect(launched).toHaveLength(0);
232
- });
233
-
234
- test('rejects request with missing signature header', async () => {
235
- const body = JSON.stringify(inProgressPayload());
236
-
237
- const res = await server.inject({
238
- method: 'POST',
239
- url: '/api/integrations/linear/webhook',
240
- headers: { 'content-type': 'application/json' },
241
- payload: body,
242
- });
243
-
244
- expect(res.statusCode).toBe(401);
245
- expect(launched).toHaveLength(0);
246
- });
247
-
248
- test('returns 401 not 500 for tampered body', async () => {
249
- const originalBody = JSON.stringify(inProgressPayload());
250
- const sig = sign(originalBody, WEBHOOK_SECRET);
251
- const tamperedBody = originalBody + ' ';
252
-
253
- const res = await server.inject({
254
- method: 'POST',
255
- url: '/api/integrations/linear/webhook',
256
- headers: { 'content-type': 'application/json', 'linear-signature': sig },
257
- payload: tamperedBody,
258
- });
259
-
260
- expect(res.statusCode).toBe(401);
261
- expect(launched).toHaveLength(0);
262
- });
263
- });
264
-
265
- describe('POST /api/integrations/linear/webhook — with API key auth enabled', () => {
266
- let server: ReturnType<typeof makeServer>['server'];
267
- let launched: ReturnType<typeof makeServer>['launched'];
268
- let cleanup: () => void;
269
-
270
- beforeEach(() => {
271
- ({ server, launched, cleanup } = makeServer({ withApiKey: true }));
272
- });
273
-
274
- afterEach(() => {
275
- cleanup();
276
- });
277
-
278
- test('webhook endpoint is exempt from Bearer token auth', async () => {
279
- // No Authorization header — should not get 401 from the API key check
280
- const payload = inProgressPayload();
281
- const res = await server.inject({
282
- method: 'POST',
283
- url: '/api/integrations/linear/webhook',
284
- headers: { 'content-type': 'application/json' },
285
- payload: JSON.stringify(payload),
286
- });
287
-
288
- expect(res.statusCode).toBe(202);
289
- expect(launched).toHaveLength(1);
290
- });
291
-
292
- test('other API routes still require Bearer token', async () => {
293
- const res = await server.inject({
294
- method: 'GET',
295
- url: '/api/runs',
296
- });
297
-
298
- expect(res.statusCode).toBe(401);
299
- });
300
- });
301
-
302
- describe('GET /api/integrations/linear', () => {
303
- let server: ReturnType<typeof makeServer>['server'];
304
- let integrationStore: ReturnType<typeof makeServer>['integrationStore'];
305
- let cleanup: () => void;
306
-
307
- beforeEach(() => {
308
- ({ server, integrationStore, cleanup } = makeServer({ active: false }));
309
- });
310
-
311
- afterEach(() => {
312
- cleanup();
313
- });
314
-
315
- test('returns default config with empty trigger log', async () => {
316
- const res = await server.inject({ method: 'GET', url: '/api/integrations/linear' });
317
-
318
- expect(res.statusCode).toBe(200);
319
- const body = res.json<{ webhook_url: string; pipeline: null; active: boolean; triggers: unknown[] }>();
320
- expect(body.webhook_url).toMatch(/\/api\/integrations\/linear\/webhook$/);
321
- expect(body.active).toBe(false);
322
- expect(body.triggers).toEqual([]);
323
- });
324
-
325
- test('reflects config and trigger records after updates', async () => {
326
- integrationStore.patchConfig('linear', { pipeline: 'feature-builder-cc-linear', active: true });
327
-
328
- const res = await server.inject({ method: 'GET', url: '/api/integrations/linear' });
329
- const body = res.json<{ pipeline: string; active: boolean }>();
330
- expect(body.pipeline).toBe('feature-builder-cc-linear');
331
- expect(body.active).toBe(true);
332
- });
333
- });
334
-
335
- describe('PATCH /api/integrations/linear', () => {
336
- let server: ReturnType<typeof makeServer>['server'];
337
- let cleanup: () => void;
338
-
339
- beforeEach(() => {
340
- ({ server, cleanup } = makeServer({ active: false }));
341
- });
342
-
343
- afterEach(() => {
344
- cleanup();
345
- });
346
-
347
- test('updates pipeline and active flag', async () => {
348
- const res = await server.inject({
349
- method: 'PATCH',
350
- url: '/api/integrations/linear',
351
- headers: { 'content-type': 'application/json' },
352
- payload: JSON.stringify({ pipeline: 'feature-builder-cc-linear', active: true }),
353
- });
354
-
355
- expect(res.statusCode).toBe(200);
356
- const body = res.json<{ pipeline: string; active: boolean }>();
357
- expect(body.pipeline).toBe('feature-builder-cc-linear');
358
- expect(body.active).toBe(true);
359
- });
360
- });
361
-
362
- describe('POST /api/integrations/linear/webhook — active flag', () => {
363
- let server: ReturnType<typeof makeServer>['server'];
364
- let launched: ReturnType<typeof makeServer>['launched'];
365
- let cleanup: () => void;
366
-
367
- beforeEach(() => {
368
- ({ server, launched, cleanup } = makeServer({ active: false }));
369
- });
370
-
371
- afterEach(() => {
372
- cleanup();
373
- });
374
-
375
- test('ignores webhook when integration is inactive', async () => {
376
- const payload = inProgressPayload();
377
- const res = await server.inject({
378
- method: 'POST',
379
- url: '/api/integrations/linear/webhook',
380
- headers: { 'content-type': 'application/json' },
381
- payload: JSON.stringify(payload),
382
- });
383
-
384
- expect(res.statusCode).toBe(200);
385
- const body = res.json<{ ignored: boolean; reason: string }>();
386
- expect(body.ignored).toBe(true);
387
- expect(body.reason).toContain('inactive');
388
- expect(launched).toHaveLength(0);
389
- });
390
- });
@@ -1,166 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync, existsSync } 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_DIR = resolve('/tmp', `.studio-api-pipelines-test-${Date.now()}`);
10
- const PIPELINES_DIR = resolve(TMP_DIR, 'pipelines');
11
-
12
- const FEATURE_BUILDER_YAML = `name: feature-builder
13
- stages:
14
- - name: analysis
15
- agent: analyst
16
- `;
17
-
18
- beforeAll(() => {
19
- mkdirSync(PIPELINES_DIR, { recursive: true });
20
- writeFileSync(resolve(PIPELINES_DIR, 'feature-builder.pipeline.yaml'), FEATURE_BUILDER_YAML);
21
- writeFileSync(resolve(PIPELINES_DIR, 'code-review.pipeline.yaml'), 'name: code-review\nstages: []\n');
22
- writeFileSync(resolve(PIPELINES_DIR, 'not-a-pipeline.yaml'), ''); // should be ignored
23
- });
24
-
25
- afterAll(() => {
26
- rmSync(TMP_DIR, { recursive: true, force: true });
27
- });
28
-
29
- const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
30
- const nullIntegrationStore = {} as unknown as IntegrationStore;
31
-
32
- function makeServer() {
33
- return buildServer({
34
- store: new InMemoryRunStore(),
35
- launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
36
- configsDir: TMP_DIR,
37
- projectName: 'test-project',
38
- apiConfig: {},
39
- integrationRuntime: nullIntegrationRuntime,
40
- integrationStore: nullIntegrationStore,
41
- });
42
- }
43
-
44
- describe('GET /api/pipelines', () => {
45
- it('returns all .pipeline.yaml names', async () => {
46
- const server = makeServer();
47
- const res = await server.inject({ method: 'GET', url: '/api/pipelines' });
48
- expect(res.statusCode).toBe(200);
49
- const { pipelines } = res.json() as { pipelines: string[] };
50
- expect(pipelines).toContain('feature-builder');
51
- expect(pipelines).toContain('code-review');
52
- expect(pipelines).not.toContain('not-a-pipeline');
53
- expect(pipelines).not.toContain('feature-builder.pipeline.yaml');
54
- });
55
-
56
- it('returns empty list when no pipelines exist', async () => {
57
- const emptyDir = resolve('/tmp', `.studio-api-empty-${Date.now()}`);
58
- mkdirSync(emptyDir, { recursive: true });
59
- const server = buildServer({
60
- store: new InMemoryRunStore(),
61
- launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
62
- configsDir: emptyDir,
63
- projectName: 'empty',
64
- apiConfig: {},
65
- integrationRuntime: nullIntegrationRuntime,
66
- integrationStore: nullIntegrationStore,
67
- });
68
- const res = await server.inject({ method: 'GET', url: '/api/pipelines' });
69
- expect(res.statusCode).toBe(200);
70
- expect((res.json() as { pipelines: string[] }).pipelines).toEqual([]);
71
- rmSync(emptyDir, { recursive: true, force: true });
72
- });
73
- });
74
-
75
- describe('GET /api/pipelines/:name', () => {
76
- it('returns the parsed pipeline YAML as JSON', async () => {
77
- const server = makeServer();
78
- const res = await server.inject({ method: 'GET', url: '/api/pipelines/feature-builder' });
79
- expect(res.statusCode).toBe(200);
80
- const body = res.json() as { name: string; stages: unknown[] };
81
- expect(body.name).toBe('feature-builder');
82
- expect(body.stages).toHaveLength(1);
83
- });
84
-
85
- it('returns 404 for unknown pipeline', async () => {
86
- const server = makeServer();
87
- const res = await server.inject({ method: 'GET', url: '/api/pipelines/nonexistent' });
88
- expect(res.statusCode).toBe(404);
89
- expect((res.json() as { error: string }).error).toBeTruthy();
90
- });
91
- });
92
-
93
- describe('PUT /api/pipelines/:name', () => {
94
- it('creates a new pipeline from YAML body', async () => {
95
- const server = makeServer();
96
- const yaml = 'name: new-pipeline\nstages: []\n';
97
- const res = await server.inject({
98
- method: 'PUT',
99
- url: '/api/pipelines/new-pipeline',
100
- headers: { 'content-type': 'text/plain' },
101
- payload: yaml,
102
- });
103
- expect(res.statusCode).toBe(200);
104
- expect(existsSync(resolve(PIPELINES_DIR, 'new-pipeline.pipeline.yaml'))).toBe(true);
105
- });
106
-
107
- it('updates an existing pipeline from YAML body', async () => {
108
- const server = makeServer();
109
- const yaml = 'name: feature-builder\nstages: []\n';
110
- const res = await server.inject({
111
- method: 'PUT',
112
- url: '/api/pipelines/feature-builder',
113
- headers: { 'content-type': 'text/plain' },
114
- payload: yaml,
115
- });
116
- expect(res.statusCode).toBe(200);
117
-
118
- // Verify content updated
119
- const get = await server.inject({ method: 'GET', url: '/api/pipelines/feature-builder' });
120
- const body = get.json() as { stages: unknown[] };
121
- expect(body.stages).toHaveLength(0);
122
- });
123
-
124
- it('creates a pipeline from JSON body', async () => {
125
- const server = makeServer();
126
- const res = await server.inject({
127
- method: 'PUT',
128
- url: '/api/pipelines/json-pipeline',
129
- headers: { 'content-type': 'application/json' },
130
- payload: { name: 'json-pipeline', stages: [] },
131
- });
132
- expect(res.statusCode).toBe(200);
133
- expect(existsSync(resolve(PIPELINES_DIR, 'json-pipeline.pipeline.yaml'))).toBe(true);
134
- });
135
-
136
- it('returns 400 for invalid YAML', async () => {
137
- const server = makeServer();
138
- const res = await server.inject({
139
- method: 'PUT',
140
- url: '/api/pipelines/bad-pipeline',
141
- headers: { 'content-type': 'text/plain' },
142
- payload: 'key: [unclosed\n - item',
143
- });
144
- expect(res.statusCode).toBe(400);
145
- expect((res.json() as { error: string }).error).toBeTruthy();
146
- });
147
- });
148
-
149
- describe('DELETE /api/pipelines/:name', () => {
150
- it('deletes an existing pipeline', async () => {
151
- const server = makeServer();
152
- // Create a file to delete
153
- writeFileSync(resolve(PIPELINES_DIR, 'to-delete.pipeline.yaml'), 'name: to-delete\n');
154
-
155
- const res = await server.inject({ method: 'DELETE', url: '/api/pipelines/to-delete' });
156
- expect(res.statusCode).toBe(200);
157
- expect(existsSync(resolve(PIPELINES_DIR, 'to-delete.pipeline.yaml'))).toBe(false);
158
- });
159
-
160
- it('returns 404 for non-existent pipeline', async () => {
161
- const server = makeServer();
162
- const res = await server.inject({ method: 'DELETE', url: '/api/pipelines/ghost-pipeline' });
163
- expect(res.statusCode).toBe(404);
164
- expect((res.json() as { error: string }).error).toBeTruthy();
165
- });
166
- });