@lovenyberg/ove 0.7.0 → 0.9.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.
@@ -1,10 +1,12 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { createHmac } from "node:crypto";
2
3
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
3
4
  import { TraceStore } from "../trace";
4
5
  import { TaskQueue } from "../queue";
5
6
  import type { IncomingEvent } from "./types";
6
7
 
7
8
  let adapter: any;
9
+ let queue: TaskQueue;
8
10
  let receivedEvents: IncomingEvent[];
9
11
  const TEST_PORT = 19876;
10
12
  const API_KEY = "test-key-123";
@@ -15,7 +17,7 @@ describe("HttpApiAdapter", () => {
15
17
  receivedEvents = [];
16
18
  const db = new Database(":memory:");
17
19
  const trace = new TraceStore(db);
18
- const queue = new TaskQueue(db);
20
+ queue = new TaskQueue(db);
19
21
  adapter = new HttpApiAdapter(TEST_PORT, API_KEY, trace, queue);
20
22
  await adapter.start((event: IncomingEvent) => {
21
23
  receivedEvents.push(event);
@@ -84,10 +86,604 @@ describe("HttpApiAdapter", () => {
84
86
  expect(result.result).toBe("Done. Fixed it.");
85
87
  });
86
88
 
89
+ test("GET /api/tasks includes priority field", async () => {
90
+ // Enqueue a task with a specific priority via the queue directly
91
+ const db = new Database(":memory:");
92
+ const queue = new TaskQueue(db);
93
+ const taskId = queue.enqueue({ userId: "u1", repo: "test/repo", prompt: "do stuff", priority: 5 });
94
+
95
+ // Create a separate adapter with this queue
96
+ const { HttpApiAdapter } = await import("./http");
97
+ const trace = new TraceStore(db);
98
+ const taskAdapter = new HttpApiAdapter(19877, API_KEY, trace, queue);
99
+ await taskAdapter.start(() => {});
100
+
101
+ try {
102
+ const res = await fetch(`http://localhost:19877/api/tasks`, {
103
+ headers: { "X-API-Key": API_KEY },
104
+ });
105
+ expect(res.status).toBe(200);
106
+ const tasks = await res.json();
107
+ expect(tasks.length).toBeGreaterThan(0);
108
+ const task = tasks.find((t: any) => t.id === taskId);
109
+ expect(task).toBeDefined();
110
+ expect(task.priority).toBe(5);
111
+ } finally {
112
+ await taskAdapter.stop();
113
+ }
114
+ });
115
+
87
116
  test("serves web UI at /", async () => {
88
117
  const res = await fetch(`http://localhost:${TEST_PORT}/`);
89
118
  expect(res.status).toBe(200);
90
119
  const html = await res.text();
91
120
  expect(html).toContain("<html");
92
121
  });
122
+
123
+ test("GET /api/metrics requires auth", async () => {
124
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/metrics`);
125
+ expect(res.status).toBe(401);
126
+ });
127
+
128
+ test("GET /api/metrics returns metrics data", async () => {
129
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/metrics`, {
130
+ headers: { "X-API-Key": API_KEY },
131
+ });
132
+ expect(res.status).toBe(200);
133
+ const data = await res.json();
134
+ expect(data.counts).toBeDefined();
135
+ expect(data.counts.pending).toBeGreaterThanOrEqual(0);
136
+ expect(data.counts.running).toBeGreaterThanOrEqual(0);
137
+ expect(data.counts.completed).toBeGreaterThanOrEqual(0);
138
+ expect(data.counts.failed).toBeGreaterThanOrEqual(0);
139
+ expect(data.avgDurationByRepo).toBeInstanceOf(Array);
140
+ expect(data.throughput).toBeDefined();
141
+ expect(typeof data.throughput.lastHour).toBe("number");
142
+ expect(typeof data.throughput.last24h).toBe("number");
143
+ expect(typeof data.errorRate).toBe("number");
144
+ expect(data.repoBreakdown).toBeInstanceOf(Array);
145
+ expect(data.adapters).toBeInstanceOf(Array);
146
+ expect(typeof data.uptime).toBe("number");
147
+ expect(data.timestamp).toBeDefined();
148
+ });
149
+
150
+ test("GET /metrics serves metrics page", async () => {
151
+ const res = await fetch(`http://localhost:${TEST_PORT}/metrics`);
152
+ expect(res.status).toBe(200);
153
+ const html = await res.text();
154
+ expect(html).toContain("<html");
155
+ expect(html).toContain("metrics");
156
+ });
157
+
158
+ // ── Cancel API tests ──
159
+
160
+ test("POST /api/tasks/:id/cancel rejects without API key", async () => {
161
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/tasks/fake-id/cancel`, {
162
+ method: "POST",
163
+ });
164
+ expect(res.status).toBe(401);
165
+ });
166
+
167
+ test("POST /api/tasks/:id/cancel returns 404 for unknown task", async () => {
168
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/tasks/nonexistent-task-id/cancel`, {
169
+ method: "POST",
170
+ headers: { "X-API-Key": API_KEY },
171
+ });
172
+ expect(res.status).toBe(404);
173
+ const body = await res.json();
174
+ expect(body.error).toBe("Task not found");
175
+ });
176
+
177
+ test("POST /api/tasks/:id/cancel cancels a pending task", async () => {
178
+ const taskId = queue.enqueue({
179
+ userId: "http:web",
180
+ repo: "test-repo",
181
+ prompt: "test cancel pending",
182
+ });
183
+
184
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/tasks/${taskId}/cancel`, {
185
+ method: "POST",
186
+ headers: { "X-API-Key": API_KEY },
187
+ });
188
+ expect(res.status).toBe(200);
189
+ const body = await res.json();
190
+ expect(body.ok).toBe(true);
191
+ expect(body.cancelled).toBe(true);
192
+ expect(body.taskId).toBe(taskId);
193
+
194
+ // Verify it's cancelled in the queue
195
+ const task = queue.get(taskId);
196
+ expect(task?.status).toBe("failed");
197
+ expect(task?.result).toBe("Cancelled");
198
+ });
199
+
200
+ test("POST /api/tasks/:id/cancel returns 409 for already completed task", async () => {
201
+ const taskId = queue.enqueue({
202
+ userId: "http:web",
203
+ repo: "test-repo",
204
+ prompt: "test cancel completed",
205
+ });
206
+ // Complete the task
207
+ queue.complete(taskId, "done");
208
+
209
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/tasks/${taskId}/cancel`, {
210
+ method: "POST",
211
+ headers: { "X-API-Key": API_KEY },
212
+ });
213
+ expect(res.status).toBe(409);
214
+ const body = await res.json();
215
+ expect(body.error).toBe("Task is not cancellable");
216
+ });
217
+
218
+ test("POST /api/tasks/:id/cancel aborts a running task", async () => {
219
+ const taskId = queue.enqueue({
220
+ userId: "http:web",
221
+ repo: "cancel-repo",
222
+ prompt: "test cancel running",
223
+ });
224
+ // Dequeue to make it running
225
+ queue.dequeue();
226
+
227
+ const task = queue.get(taskId);
228
+ expect(task?.status).toBe("running");
229
+
230
+ // Register a mock running process
231
+ const abortController = new AbortController();
232
+ const runningProcesses = new Map();
233
+ runningProcesses.set(taskId, { abort: abortController, task });
234
+ adapter.setRunningProcesses(runningProcesses);
235
+
236
+ const res = await fetch(`http://localhost:${TEST_PORT}/api/tasks/${taskId}/cancel`, {
237
+ method: "POST",
238
+ headers: { "X-API-Key": API_KEY },
239
+ });
240
+ expect(res.status).toBe(200);
241
+ const body = await res.json();
242
+ expect(body.ok).toBe(true);
243
+ expect(body.cancelled).toBe(true);
244
+
245
+ // Verify abort was called
246
+ expect(abortController.signal.aborted).toBe(true);
247
+
248
+ // Verify queue status
249
+ const updated = queue.get(taskId);
250
+ expect(updated?.status).toBe("failed");
251
+ expect(updated?.result).toBe("Cancelled");
252
+ });
253
+ });
254
+
255
+ // --- Webhook tests ---
256
+
257
+ const WEBHOOK_PORT = 19879;
258
+ const WEBHOOK_API_KEY = "webhook-test-key";
259
+ const WEBHOOK_SECRET = "test-webhook-secret-123";
260
+ const BOT_NAME = "ove";
261
+
262
+ function signPayload(secret: string, body: string): string {
263
+ return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
264
+ }
265
+
266
+ function makeIssueCommentPayload(overrides?: Record<string, any>) {
267
+ return {
268
+ action: "created",
269
+ comment: {
270
+ body: `@${BOT_NAME} fix the flaky test`,
271
+ user: { login: "testuser" },
272
+ id: 12345,
273
+ },
274
+ issue: {
275
+ number: 42,
276
+ pull_request: undefined,
277
+ },
278
+ repository: {
279
+ full_name: "acme/my-app",
280
+ },
281
+ ...overrides,
282
+ };
283
+ }
284
+
285
+ function makePRReviewCommentPayload(overrides?: Record<string, any>) {
286
+ return {
287
+ action: "created",
288
+ comment: {
289
+ body: `@${BOT_NAME} refactor this function`,
290
+ user: { login: "reviewer" },
291
+ id: 67890,
292
+ },
293
+ pull_request: {
294
+ number: 99,
295
+ },
296
+ repository: {
297
+ full_name: "acme/my-app",
298
+ },
299
+ ...overrides,
300
+ };
301
+ }
302
+
303
+ describe("GitHub webhook endpoint", () => {
304
+ let webhookAdapter: any;
305
+ let webhookEvents: IncomingEvent[];
306
+
307
+ beforeAll(async () => {
308
+ const { HttpApiAdapter } = await import("./http");
309
+ webhookEvents = [];
310
+ const db = new Database(":memory:");
311
+ const trace = new TraceStore(db);
312
+ const queue = new TaskQueue(db);
313
+ webhookAdapter = new HttpApiAdapter(
314
+ WEBHOOK_PORT, WEBHOOK_API_KEY, trace, queue,
315
+ undefined, "127.0.0.1", WEBHOOK_SECRET, BOT_NAME
316
+ );
317
+ await webhookAdapter.start((event: IncomingEvent) => {
318
+ webhookEvents.push(event);
319
+ });
320
+ });
321
+
322
+ afterAll(async () => {
323
+ await webhookAdapter.stop();
324
+ });
325
+
326
+ test("accepts issue_comment with valid signature", async () => {
327
+ const payload = makeIssueCommentPayload();
328
+ const body = JSON.stringify(payload);
329
+ const signature = signPayload(WEBHOOK_SECRET, body);
330
+
331
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
332
+ method: "POST",
333
+ headers: {
334
+ "Content-Type": "application/json",
335
+ "X-Hub-Signature-256": signature,
336
+ "X-GitHub-Event": "issue_comment",
337
+ },
338
+ body,
339
+ });
340
+
341
+ expect(res.status).toBe(200);
342
+ const result = await res.json();
343
+ expect(result.ok).toBe(true);
344
+ expect(result.eventId).toBe("github:acme/my-app:issue:42");
345
+
346
+ expect(webhookEvents.length).toBe(1);
347
+ expect(webhookEvents[0].text).toBe("fix the flaky test");
348
+ expect(webhookEvents[0].userId).toBe("github:testuser");
349
+ expect(webhookEvents[0].platform).toBe("github");
350
+ expect(webhookEvents[0].source).toEqual({ type: "issue", repo: "acme/my-app", number: 42 });
351
+ });
352
+
353
+ test("accepts pull_request_review_comment with valid signature", async () => {
354
+ webhookEvents.length = 0;
355
+ const payload = makePRReviewCommentPayload();
356
+ const body = JSON.stringify(payload);
357
+ const signature = signPayload(WEBHOOK_SECRET, body);
358
+
359
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
360
+ method: "POST",
361
+ headers: {
362
+ "Content-Type": "application/json",
363
+ "X-Hub-Signature-256": signature,
364
+ "X-GitHub-Event": "pull_request_review_comment",
365
+ },
366
+ body,
367
+ });
368
+
369
+ expect(res.status).toBe(200);
370
+ const result = await res.json();
371
+ expect(result.ok).toBe(true);
372
+ expect(result.eventId).toBe("github:acme/my-app:pr:99");
373
+
374
+ expect(webhookEvents.length).toBe(1);
375
+ expect(webhookEvents[0].text).toBe("refactor this function");
376
+ expect(webhookEvents[0].userId).toBe("github:reviewer");
377
+ expect(webhookEvents[0].source).toEqual({ type: "pr", repo: "acme/my-app", number: 99 });
378
+ });
379
+
380
+ test("rejects GitHub webhook with invalid signature", async () => {
381
+ webhookEvents.length = 0;
382
+ const payload = makeIssueCommentPayload();
383
+ const body = JSON.stringify(payload);
384
+
385
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
386
+ method: "POST",
387
+ headers: {
388
+ "Content-Type": "application/json",
389
+ "X-Hub-Signature-256": "sha256=invalid_signature_here",
390
+ "X-GitHub-Event": "issue_comment",
391
+ },
392
+ body,
393
+ });
394
+
395
+ expect(res.status).toBe(401);
396
+ const result = await res.json();
397
+ expect(result.error).toBe("Invalid signature");
398
+ expect(webhookEvents.length).toBe(0);
399
+ });
400
+
401
+ test("rejects GitHub webhook without signature header", async () => {
402
+ const payload = makeIssueCommentPayload();
403
+ const body = JSON.stringify(payload);
404
+
405
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
406
+ method: "POST",
407
+ headers: {
408
+ "Content-Type": "application/json",
409
+ "X-GitHub-Event": "issue_comment",
410
+ },
411
+ body,
412
+ });
413
+
414
+ expect(res.status).toBe(401);
415
+ const result = await res.json();
416
+ expect(result.error).toBe("Missing signature");
417
+ });
418
+
419
+ test("skips comments without @mention", async () => {
420
+ webhookEvents.length = 0;
421
+ const payload = makeIssueCommentPayload({
422
+ comment: { body: "just a regular comment", user: { login: "testuser" }, id: 111 },
423
+ });
424
+ const body = JSON.stringify(payload);
425
+ const signature = signPayload(WEBHOOK_SECRET, body);
426
+
427
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
428
+ method: "POST",
429
+ headers: {
430
+ "Content-Type": "application/json",
431
+ "X-Hub-Signature-256": signature,
432
+ "X-GitHub-Event": "issue_comment",
433
+ },
434
+ body,
435
+ });
436
+
437
+ expect(res.status).toBe(200);
438
+ const result = await res.json();
439
+ expect(result.skipped).toBe(true);
440
+ expect(result.reason).toBe("No mention found");
441
+ expect(webhookEvents.length).toBe(0);
442
+ });
443
+
444
+ test("skips unsupported GitHub event types", async () => {
445
+ const payload = makeIssueCommentPayload();
446
+ const body = JSON.stringify(payload);
447
+ const signature = signPayload(WEBHOOK_SECRET, body);
448
+
449
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
450
+ method: "POST",
451
+ headers: {
452
+ "Content-Type": "application/json",
453
+ "X-Hub-Signature-256": signature,
454
+ "X-GitHub-Event": "push",
455
+ },
456
+ body,
457
+ });
458
+
459
+ expect(res.status).toBe(200);
460
+ const result = await res.json();
461
+ expect(result.skipped).toBe(true);
462
+ expect(result.reason).toContain("Unsupported event");
463
+ });
464
+
465
+ test("skips bot's own comments to prevent infinite loops", async () => {
466
+ webhookEvents.length = 0;
467
+ const payload = makeIssueCommentPayload({
468
+ comment: { body: `@${BOT_NAME} fix the flaky test`, user: { login: BOT_NAME }, id: 99999 },
469
+ });
470
+ const body = JSON.stringify(payload);
471
+ const signature = signPayload(WEBHOOK_SECRET, body);
472
+
473
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
474
+ method: "POST",
475
+ headers: {
476
+ "Content-Type": "application/json",
477
+ "X-Hub-Signature-256": signature,
478
+ "X-GitHub-Event": "issue_comment",
479
+ },
480
+ body,
481
+ });
482
+
483
+ expect(res.status).toBe(200);
484
+ const result = await res.json();
485
+ expect(result.ok).toBe(true);
486
+ expect(result.skipped).toBe(true);
487
+ expect(result.reason).toBe("Own comment");
488
+ expect(webhookEvents.length).toBe(0);
489
+ });
490
+
491
+ test("rejects oversized GitHub webhook payload", async () => {
492
+ webhookEvents.length = 0;
493
+ // Create a payload larger than 1MB
494
+ const payload = makeIssueCommentPayload({
495
+ comment: { body: `@${BOT_NAME} ${"x".repeat(1_100_000)}`, user: { login: "testuser" }, id: 88888 },
496
+ });
497
+ const body = JSON.stringify(payload);
498
+ const signature = signPayload(WEBHOOK_SECRET, body);
499
+
500
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
501
+ method: "POST",
502
+ headers: {
503
+ "Content-Type": "application/json",
504
+ "X-Hub-Signature-256": signature,
505
+ "X-GitHub-Event": "issue_comment",
506
+ },
507
+ body,
508
+ });
509
+
510
+ expect(res.status).toBe(413);
511
+ const result = await res.json();
512
+ expect(result.error).toContain("Payload too large");
513
+ expect(webhookEvents.length).toBe(0);
514
+ });
515
+
516
+ test("detects PR from issue_comment on a pull request", async () => {
517
+ webhookEvents.length = 0;
518
+ const payload = makeIssueCommentPayload({
519
+ issue: { number: 55, pull_request: { url: "https://api.github.com/repos/acme/my-app/pulls/55" } },
520
+ });
521
+ const body = JSON.stringify(payload);
522
+ const signature = signPayload(WEBHOOK_SECRET, body);
523
+
524
+ const res = await fetch(`http://localhost:${WEBHOOK_PORT}/api/webhooks/github`, {
525
+ method: "POST",
526
+ headers: {
527
+ "Content-Type": "application/json",
528
+ "X-Hub-Signature-256": signature,
529
+ "X-GitHub-Event": "issue_comment",
530
+ },
531
+ body,
532
+ });
533
+
534
+ expect(res.status).toBe(200);
535
+ expect(webhookEvents.length).toBe(1);
536
+ expect(webhookEvents[0].source).toEqual({ type: "pr", repo: "acme/my-app", number: 55 });
537
+ });
538
+ });
539
+
540
+ describe("Generic webhook endpoint", () => {
541
+ let genericAdapter: any;
542
+ let genericEvents: IncomingEvent[];
543
+ const GENERIC_PORT = 19880;
544
+
545
+ beforeAll(async () => {
546
+ const { HttpApiAdapter } = await import("./http");
547
+ genericEvents = [];
548
+ const db = new Database(":memory:");
549
+ const trace = new TraceStore(db);
550
+ const queue = new TaskQueue(db);
551
+ genericAdapter = new HttpApiAdapter(GENERIC_PORT, WEBHOOK_API_KEY, trace, queue);
552
+ await genericAdapter.start((event: IncomingEvent) => {
553
+ genericEvents.push(event);
554
+ });
555
+ });
556
+
557
+ afterAll(async () => {
558
+ await genericAdapter.stop();
559
+ });
560
+
561
+ test("accepts generic webhook with valid API key", async () => {
562
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
563
+ method: "POST",
564
+ headers: {
565
+ "Content-Type": "application/json",
566
+ "X-API-Key": WEBHOOK_API_KEY,
567
+ },
568
+ body: JSON.stringify({
569
+ repo: "my-app",
570
+ text: "run tests",
571
+ userId: "webhook:ci",
572
+ }),
573
+ });
574
+
575
+ expect(res.status).toBe(202);
576
+ const result = await res.json();
577
+ expect(result.ok).toBe(true);
578
+ expect(result.eventId).toBeDefined();
579
+
580
+ expect(genericEvents.length).toBe(1);
581
+ expect(genericEvents[0].text).toBe("run tests");
582
+ expect(genericEvents[0].userId).toBe("webhook:ci");
583
+ expect(genericEvents[0].platform).toBe("webhook");
584
+ });
585
+
586
+ test("rejects generic webhook without API key", async () => {
587
+ genericEvents.length = 0;
588
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
589
+ method: "POST",
590
+ headers: { "Content-Type": "application/json" },
591
+ body: JSON.stringify({
592
+ repo: "my-app",
593
+ text: "run tests",
594
+ }),
595
+ });
596
+
597
+ expect(res.status).toBe(401);
598
+ expect(genericEvents.length).toBe(0);
599
+ });
600
+
601
+ test("rejects generic webhook with missing repo", async () => {
602
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
603
+ method: "POST",
604
+ headers: {
605
+ "Content-Type": "application/json",
606
+ "X-API-Key": WEBHOOK_API_KEY,
607
+ },
608
+ body: JSON.stringify({ text: "run tests" }),
609
+ });
610
+
611
+ expect(res.status).toBe(400);
612
+ const result = await res.json();
613
+ expect(result.error).toContain("repo");
614
+ });
615
+
616
+ test("rejects generic webhook with missing text", async () => {
617
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
618
+ method: "POST",
619
+ headers: {
620
+ "Content-Type": "application/json",
621
+ "X-API-Key": WEBHOOK_API_KEY,
622
+ },
623
+ body: JSON.stringify({ repo: "my-app" }),
624
+ });
625
+
626
+ expect(res.status).toBe(400);
627
+ const result = await res.json();
628
+ expect(result.error).toContain("text");
629
+ });
630
+
631
+ test("passes repo field through in EventSource", async () => {
632
+ genericEvents.length = 0;
633
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
634
+ method: "POST",
635
+ headers: {
636
+ "Content-Type": "application/json",
637
+ "X-API-Key": WEBHOOK_API_KEY,
638
+ },
639
+ body: JSON.stringify({
640
+ repo: "acme/my-service",
641
+ text: "deploy to staging",
642
+ }),
643
+ });
644
+
645
+ expect(res.status).toBe(202);
646
+ expect(genericEvents.length).toBe(1);
647
+ expect(genericEvents[0].source).toMatchObject({ type: "http", repo: "acme/my-service" });
648
+ expect((genericEvents[0].source as any).repo).toBe("acme/my-service");
649
+ });
650
+
651
+ test("rejects oversized generic webhook payload", async () => {
652
+ genericEvents.length = 0;
653
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
654
+ method: "POST",
655
+ headers: {
656
+ "Content-Type": "application/json",
657
+ "X-API-Key": WEBHOOK_API_KEY,
658
+ },
659
+ body: JSON.stringify({
660
+ repo: "my-app",
661
+ text: "x".repeat(1_100_000),
662
+ }),
663
+ });
664
+
665
+ expect(res.status).toBe(413);
666
+ const result = await res.json();
667
+ expect(result.error).toContain("Payload too large");
668
+ expect(genericEvents.length).toBe(0);
669
+ });
670
+
671
+ test("uses default userId when not provided", async () => {
672
+ genericEvents.length = 0;
673
+ const res = await fetch(`http://localhost:${GENERIC_PORT}/api/webhooks/generic`, {
674
+ method: "POST",
675
+ headers: {
676
+ "Content-Type": "application/json",
677
+ "X-API-Key": WEBHOOK_API_KEY,
678
+ },
679
+ body: JSON.stringify({
680
+ repo: "my-app",
681
+ text: "deploy",
682
+ }),
683
+ });
684
+
685
+ expect(res.status).toBe(202);
686
+ expect(genericEvents.length).toBe(1);
687
+ expect(genericEvents[0].userId).toBe("webhook:generic");
688
+ });
93
689
  });