Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

webhook-delivery.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

webhook-delivery.test.tsBlame256 lines · 1 contributor
8405c43Claude1/**
2 * Reliable webhook delivery — retry queue + dead-letter coverage.
3 *
4 * Pins down `src/lib/webhook-delivery.ts`:
5 * - 2xx response on the first attempt → status='succeeded' + succeeded_at
6 * - 5xx response → row stays 'pending', attempt_count increments,
7 * next_attempt_at lands ~30s out (first backoff step)
8 * - After MAX_ATTEMPTS-1 consecutive failures the next attempt flips the
9 * row to status='dead' (no further retries)
10 *
11 * Strategy: each test spins up a tiny Bun.serve() on a random port and points
12 * a freshly-created `webhooks` row at it, then drives `attemptDelivery()`
13 * directly so we don't depend on worker timing.
14 *
15 * DB-backed only — gated on `DATABASE_URL` to keep CI green without
16 * Postgres, matching the `HAS_DB` skipIf pattern used elsewhere.
17 */
18
19import { describe, it, expect } from "bun:test";
20
21const HAS_DB = Boolean(process.env.DATABASE_URL);
22
23// ---------------------------------------------------------------------------
24// Pure logic — backoff table shape (runs without a DB).
25// ---------------------------------------------------------------------------
26
27describe("webhook-delivery — backoff schedule", () => {
28 it("defines MAX_ATTEMPTS=6 and monotonically increasing backoffs", async () => {
29 const { MAX_ATTEMPTS, BACKOFF_MS } = await import(
30 "../lib/webhook-delivery"
31 );
32 expect(MAX_ATTEMPTS).toBe(6);
33 // Indices 1..5 hold the schedule (slot 0 is the unused immediate slot).
34 expect(BACKOFF_MS[1]).toBe(30_000);
35 expect(BACKOFF_MS[2]).toBe(120_000);
36 expect(BACKOFF_MS[3]).toBe(600_000);
37 expect(BACKOFF_MS[4]).toBe(3_600_000);
38 expect(BACKOFF_MS[5]).toBe(21_600_000);
39 for (let i = 2; i < BACKOFF_MS.length; i++) {
40 expect(BACKOFF_MS[i]).toBeGreaterThan(BACKOFF_MS[i - 1]!);
41 }
42 });
43});
44
45// ---------------------------------------------------------------------------
46// DB-backed end-to-end: spins up a tiny target server per case.
47// ---------------------------------------------------------------------------
48
49interface Fixture {
50 userId: string;
51 repoId: string;
52 webhookId: string;
53 cleanup: () => Promise<void>;
54}
55
56async function makeFixture(targetUrl: string): Promise<Fixture> {
57 const { db } = await import("../db");
58 const { users, repositories, webhooks } = await import("../db/schema");
59 const { eq } = await import("drizzle-orm");
60
61 const uname = "whd-" + Math.random().toString(36).slice(2, 10);
62 const [user] = await db
63 .insert(users)
64 .values({
65 username: uname,
66 email: `${uname}@example.com`,
67 passwordHash: "x",
68 })
69 .returning();
70
71 const [repo] = await db
72 .insert(repositories)
73 .values({
74 name: "whd-repo-" + Math.random().toString(36).slice(2, 8),
75 ownerId: user.id,
76 diskPath: `/tmp/whd-${user.id}`,
77 })
78 .returning();
79
80 const [hook] = await db
81 .insert(webhooks)
82 .values({
83 repositoryId: repo.id,
84 url: targetUrl,
85 secret: "test-secret",
86 events: "push",
87 isActive: true,
88 })
89 .returning();
90
91 return {
92 userId: user.id,
93 repoId: repo.id,
94 webhookId: hook.id,
95 cleanup: async () => {
96 // Cascade: deleting the user wipes the repo (FK) which wipes the
97 // hook (FK) which wipes the deliveries (FK).
98 try {
99 await db.delete(users).where(eq(users.id, user.id));
100 } catch {
101 /* swallow */
102 }
103 },
104 };
105}
106
107describe("webhook-delivery — successful first attempt", () => {
108 it.skipIf(!HAS_DB)("2xx → status='succeeded' + succeeded_at set", async () => {
109 const server = Bun.serve({
110 port: 0,
111 fetch: () => new Response("ok", { status: 200 }),
112 });
113 const url = `http://localhost:${server.port}/hook`;
114 const fx = await makeFixture(url);
115
116 try {
117 const { db } = await import("../db");
118 const { webhookDeliveries } = await import("../db/schema");
119 const { eq } = await import("drizzle-orm");
120 const { enqueueWebhookDelivery, attemptDelivery } = await import(
121 "../lib/webhook-delivery"
122 );
123
124 const id = await enqueueWebhookDelivery({
125 webhookId: fx.webhookId,
126 secret: "test-secret",
127 event: "push",
128 payload: { hello: "world" },
129 });
130 expect(id).toBeTruthy();
131
132 const result = await attemptDelivery(id!);
133 expect(result).toBe("succeeded");
134
135 const [row] = await db
136 .select()
137 .from(webhookDeliveries)
138 .where(eq(webhookDeliveries.id, id!));
139 expect(row.status).toBe("succeeded");
140 expect(row.attemptCount).toBe(1);
141 expect(row.lastStatusCode).toBe(200);
142 expect(row.succeededAt).toBeTruthy();
143 expect(row.nextAttemptAt).toBeNull();
144 expect(row.lastError).toBeNull();
145 } finally {
146 await fx.cleanup();
147 server.stop(true);
148 }
149 });
150});
151
152describe("webhook-delivery — retry on 5xx", () => {
153 it.skipIf(!HAS_DB)(
154 "500 → status stays 'pending', attempt_count=1, next_attempt_at ~30s out",
155 async () => {
156 const server = Bun.serve({
157 port: 0,
158 fetch: () => new Response("boom", { status: 500 }),
159 });
160 const url = `http://localhost:${server.port}/hook`;
161 const fx = await makeFixture(url);
162
163 try {
164 const { db } = await import("../db");
165 const { webhookDeliveries } = await import("../db/schema");
166 const { eq } = await import("drizzle-orm");
167 const { enqueueWebhookDelivery, attemptDelivery, BACKOFF_MS } =
168 await import("../lib/webhook-delivery");
169
170 const id = await enqueueWebhookDelivery({
171 webhookId: fx.webhookId,
172 secret: "test-secret",
173 event: "push",
174 payload: { fail: true },
175 });
176 expect(id).toBeTruthy();
177
178 const before = Date.now();
179 const result = await attemptDelivery(id!);
180 expect(result).toBe("retry");
181
182 const [row] = await db
183 .select()
184 .from(webhookDeliveries)
185 .where(eq(webhookDeliveries.id, id!));
186 expect(row.status).toBe("pending");
187 expect(row.attemptCount).toBe(1);
188 expect(row.lastStatusCode).toBe(500);
189 expect(row.lastError).toContain("500");
190 expect(row.succeededAt).toBeNull();
191 // next_attempt_at should land in the future, roughly at +BACKOFF_MS[1]
192 // (allow generous slack for slow test runners + DB rounding).
193 expect(row.nextAttemptAt).toBeTruthy();
194 const nextMs = new Date(row.nextAttemptAt!).getTime();
195 const expected = before + BACKOFF_MS[1]!;
196 expect(nextMs).toBeGreaterThanOrEqual(expected - 5_000);
197 expect(nextMs).toBeLessThanOrEqual(expected + 60_000);
198 } finally {
199 await fx.cleanup();
200 server.stop(true);
201 }
202 }
203 );
204});
205
206describe("webhook-delivery — dead-letter after max attempts", () => {
207 it.skipIf(!HAS_DB)(
208 "after MAX_ATTEMPTS consecutive failures → status='dead'",
209 async () => {
210 const server = Bun.serve({
211 port: 0,
212 fetch: () => new Response("nope", { status: 503 }),
213 });
214 const url = `http://localhost:${server.port}/hook`;
215 const fx = await makeFixture(url);
216
217 try {
218 const { db } = await import("../db");
219 const { webhookDeliveries } = await import("../db/schema");
220 const { eq } = await import("drizzle-orm");
221 const { enqueueWebhookDelivery, attemptDelivery, MAX_ATTEMPTS } =
222 await import("../lib/webhook-delivery");
223
224 const id = await enqueueWebhookDelivery({
225 webhookId: fx.webhookId,
226 secret: "test-secret",
227 event: "push",
228 payload: { will: "die" },
229 });
230 expect(id).toBeTruthy();
231
232 // Drive every attempt back-to-back. We bypass the wall-clock backoff
233 // by calling attemptDelivery() directly — the schedule check lives
234 // in the *worker*, not in the per-attempt path.
235 let last: string = "";
236 for (let i = 0; i < MAX_ATTEMPTS; i++) {
237 last = await attemptDelivery(id!);
238 }
239 expect(last).toBe("dead");
240
241 const [row] = await db
242 .select()
243 .from(webhookDeliveries)
244 .where(eq(webhookDeliveries.id, id!));
245 expect(row.status).toBe("dead");
246 expect(row.attemptCount).toBe(MAX_ATTEMPTS);
247 expect(row.lastStatusCode).toBe(503);
248 expect(row.nextAttemptAt).toBeNull();
249 expect(row.succeededAt).toBeNull();
250 } finally {
251 await fx.cleanup();
252 server.stop(true);
253 }
254 }
255 );
256});