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

branch-previews.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.

branch-previews.test.tsBlame409 lines · 1 contributor
4bbacbeClaude1/**
2 * Tests for src/lib/branch-previews.ts — per-branch preview URLs.
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — URL slugging, expires-in formatting, status
7 * label mapping. No DB, no network. Always run.
8 *
9 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green
10 * on machines without Postgres. Covers:
11 * - enqueue creates a row
12 * - re-push to the same branch DEDUPES (replaces commit_sha + URL,
13 * resets status to 'building')
14 * - markPreviewReady / markPreviewFailed flip status as expected
15 * - expireOldPreviews flips expired rows to 'expired'
16 * - listPreviewsForRepo / getPreviewForBranch round-trip
17 */
18
19import { afterEach, describe, expect, it } from "bun:test";
20import {
21 buildPreviewUrl,
22 enqueuePreviewBuild,
23 expireOldPreviews,
24 formatExpiresIn,
25 getPreviewForBranch,
26 listPreviewsForRepo,
27 markPreviewFailed,
28 markPreviewReady,
29 previewStatusLabel,
30 slugifyForUrl,
31} from "../lib/branch-previews";
32import { db } from "../db";
33import { branchPreviews, repositories, users } from "../db/schema";
34import { eq } from "drizzle-orm";
35
36const HAS_DB = Boolean(process.env.DATABASE_URL);
37
38// ---------------------------------------------------------------------------
39// 1. Pure helpers
40// ---------------------------------------------------------------------------
41
42describe("branch-previews — slugifyForUrl", () => {
43 it("lowercases and replaces non-alphanumerics with dashes", () => {
44 expect(slugifyForUrl("Feature/Branch_42")).toBe("feature-branch-42");
45 });
46
47 it("strips leading + trailing dashes", () => {
48 expect(slugifyForUrl("--foo--")).toBe("foo");
49 });
50
51 it("clips to 50 characters", () => {
52 const long = "a".repeat(200);
53 expect(slugifyForUrl(long).length).toBe(50);
54 });
55
56 it("returns empty string for empty input", () => {
57 expect(slugifyForUrl("")).toBe("");
58 });
59});
60
61describe("branch-previews — buildPreviewUrl", () => {
62 it("produces a wildcard subdomain URL with default domain", () => {
63 const prev = process.env.PREVIEW_DOMAIN;
64 delete process.env.PREVIEW_DOMAIN;
65 try {
66 const url = buildPreviewUrl("alice", "site", "feat/new");
67 expect(url).toBe("https://feat-new-alice-site.preview.gluecron.com");
68 } finally {
69 if (prev !== undefined) process.env.PREVIEW_DOMAIN = prev;
70 }
71 });
72
73 it("honours a custom PREVIEW_DOMAIN env var", () => {
74 const prev = process.env.PREVIEW_DOMAIN;
75 process.env.PREVIEW_DOMAIN = "preview.acme.dev";
76 try {
77 const url = buildPreviewUrl("a", "b", "main-thing");
78 expect(url).toBe("https://main-thing-a-b.preview.acme.dev");
79 } finally {
80 if (prev === undefined) delete process.env.PREVIEW_DOMAIN;
81 else process.env.PREVIEW_DOMAIN = prev;
82 }
83 });
84
85 it("falls back to 'branch' when branch slug is empty", () => {
86 const url = buildPreviewUrl("a", "b", "///");
87 expect(url).toContain("branch-");
88 });
89
90 it("strips scheme from PREVIEW_DOMAIN if accidentally included", () => {
91 const prev = process.env.PREVIEW_DOMAIN;
92 process.env.PREVIEW_DOMAIN = "https://preview.foo.com";
93 try {
94 const url = buildPreviewUrl("a", "b", "c");
95 expect(url).toBe("https://c-a-b.preview.foo.com");
96 } finally {
97 if (prev === undefined) delete process.env.PREVIEW_DOMAIN;
98 else process.env.PREVIEW_DOMAIN = prev;
99 }
100 });
101});
102
103describe("branch-previews — formatExpiresIn", () => {
104 it("returns 'expired' for past timestamps", () => {
105 const now = new Date("2026-01-01T12:00:00Z");
106 const past = new Date("2026-01-01T11:00:00Z");
107 expect(formatExpiresIn(past, now)).toBe("expired");
108 });
109
110 it("returns 'less than a minute' for sub-minute futures", () => {
111 const now = new Date("2026-01-01T12:00:00Z");
112 const soon = new Date("2026-01-01T12:00:30Z");
113 expect(formatExpiresIn(soon, now)).toBe("less than a minute");
114 });
115
116 it("returns just minutes when under an hour away", () => {
117 const now = new Date("2026-01-01T12:00:00Z");
118 const future = new Date("2026-01-01T12:42:00Z");
119 expect(formatExpiresIn(future, now)).toBe("42m");
120 });
121
122 it("returns hours + minutes when over an hour away", () => {
123 const now = new Date("2026-01-01T12:00:00Z");
124 const future = new Date("2026-01-02T08:30:00Z");
125 expect(formatExpiresIn(future, now)).toBe("20h 30m");
126 });
127
128 it("returns em-dash for null", () => {
129 expect(formatExpiresIn(null)).toBe("—");
130 });
131});
132
133describe("branch-previews — previewStatusLabel", () => {
134 it("maps known statuses to human labels", () => {
135 expect(previewStatusLabel("building")).toBe("Building");
136 expect(previewStatusLabel("ready")).toBe("Ready");
137 expect(previewStatusLabel("failed")).toBe("Failed");
138 expect(previewStatusLabel("expired")).toBe("Expired");
139 });
140
141 it("passes through unknown statuses unchanged", () => {
142 expect(previewStatusLabel("weird")).toBe("weird");
143 });
144});
145
146// ---------------------------------------------------------------------------
147// 2. Graceful no-ops without DB — must not throw on empty/missing inputs
148// ---------------------------------------------------------------------------
149
150describe("branch-previews — graceful no-ops", () => {
151 it("enqueuePreviewBuild returns null for missing required fields", async () => {
152 expect(
153 await enqueuePreviewBuild({
154 repositoryId: "",
155 ownerName: "a",
156 repoName: "b",
157 branchName: "c",
158 commitSha: "d",
159 })
160 ).toBeNull();
161 expect(
162 await enqueuePreviewBuild({
163 repositoryId: "x",
164 ownerName: "a",
165 repoName: "b",
166 branchName: "",
167 commitSha: "d",
168 })
169 ).toBeNull();
170 expect(
171 await enqueuePreviewBuild({
172 repositoryId: "x",
173 ownerName: "a",
174 repoName: "b",
175 branchName: "c",
176 commitSha: "",
177 })
178 ).toBeNull();
179 });
180
181 it("getPreviewForBranch returns null for empty inputs", async () => {
182 expect(await getPreviewForBranch("", "main")).toBeNull();
183 expect(await getPreviewForBranch("repo-id", "")).toBeNull();
184 });
185
186 it("listPreviewsForRepo returns [] for empty repository id", async () => {
187 expect(await listPreviewsForRepo("")).toEqual([]);
188 });
189
190 it("markPreviewReady / markPreviewFailed swallow empty ids", async () => {
191 await markPreviewReady("");
192 await markPreviewFailed("", "err");
193 // No throw = pass.
194 expect(true).toBe(true);
195 });
196});
197
198// ---------------------------------------------------------------------------
199// 3. DB-backed pipeline
200// ---------------------------------------------------------------------------
201
202const TEST_USER_PREFIX = "previewtest_";
203
204async function seedRepo(): Promise<{ userId: string; repoId: string }> {
205 const username =
206 TEST_USER_PREFIX + Math.random().toString(36).slice(2, 10);
207 const [user] = await db
208 .insert(users)
209 .values({
210 username,
211 email: `${username}@previewtest.local`,
212 passwordHash: "$2b$10$" + "x".repeat(53),
213 })
214 .returning();
215 const [repo] = await db
216 .insert(repositories)
217 .values({
218 name: "previews-test-" + Math.random().toString(36).slice(2, 8),
219 ownerId: user!.id,
220 diskPath: "/tmp/previews-test-" + Math.random().toString(36).slice(2, 8),
221 })
222 .returning();
223 return { userId: user!.id, repoId: repo!.id };
224}
225
226async function cleanupRepo(userId: string): Promise<void> {
227 try {
228 await db.delete(repositories).where(eq(repositories.ownerId, userId));
229 await db.delete(users).where(eq(users.id, userId));
230 } catch {
231 /* best effort */
232 }
233}
234
235describe.skipIf(!HAS_DB)("branch-previews — DB pipeline", () => {
236 let userId = "";
237 let repoId = "";
238
239 afterEach(async () => {
240 if (userId) await cleanupRepo(userId);
241 userId = "";
242 repoId = "";
243 });
244
245 it("enqueue creates a building row with a computed preview URL", async () => {
246 ({ userId, repoId } = await seedRepo());
247
248 const row = await enqueuePreviewBuild({
249 repositoryId: repoId,
250 ownerName: "alice",
251 repoName: "site",
252 branchName: "feat/a",
253 commitSha: "abc1234567890",
254 });
255 expect(row).not.toBeNull();
256 expect(row!.status).toBe("building");
257 expect(row!.branchName).toBe("feat/a");
258 expect(row!.commitSha).toBe("abc1234567890");
259 expect(row!.previewUrl).toContain("feat-a-");
260 expect(row!.previewUrl.startsWith("https://")).toBe(true);
261 expect(row!.expiresAt instanceof Date).toBe(true);
262 expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
263 });
264
265 it("re-pushing the same branch DEDUPES — same id, new SHA, status='building'", async () => {
266 ({ userId, repoId } = await seedRepo());
267
268 const first = await enqueuePreviewBuild({
269 repositoryId: repoId,
270 ownerName: "alice",
271 repoName: "site",
272 branchName: "feat/b",
273 commitSha: "sha-one",
274 });
275 expect(first).not.toBeNull();
276
277 // Flip to ready so we can verify the upsert resets it.
278 await markPreviewReady(first!.id);
279 let row = await getPreviewForBranch(repoId, "feat/b");
280 expect(row!.status).toBe("ready");
281
282 // Second push to the same branch → upsert path.
283 const second = await enqueuePreviewBuild({
284 repositoryId: repoId,
285 ownerName: "alice",
286 repoName: "site",
287 branchName: "feat/b",
288 commitSha: "sha-two",
289 });
290 expect(second).not.toBeNull();
291 expect(second!.id).toBe(first!.id); // same row
292 expect(second!.commitSha).toBe("sha-two");
293 expect(second!.status).toBe("building");
294 expect(second!.buildCompletedAt).toBeNull();
295
296 // And only ONE row exists for the (repo, branch) pair.
297 const all = await listPreviewsForRepo(repoId);
298 const matching = all.filter((p) => p.branchName === "feat/b");
299 expect(matching.length).toBe(1);
300 });
301
302 it("markPreviewReady flips status to 'ready' with completed_at", async () => {
303 ({ userId, repoId } = await seedRepo());
304
305 const row = await enqueuePreviewBuild({
306 repositoryId: repoId,
307 ownerName: "a",
308 repoName: "b",
309 branchName: "x",
310 commitSha: "abc",
311 });
312 await markPreviewReady(row!.id);
313 const after = await getPreviewForBranch(repoId, "x");
314 expect(after!.status).toBe("ready");
315 expect(after!.buildCompletedAt).not.toBeNull();
316 });
317
318 it("markPreviewFailed records error_message + truncates", async () => {
319 ({ userId, repoId } = await seedRepo());
320
321 const row = await enqueuePreviewBuild({
322 repositoryId: repoId,
323 ownerName: "a",
324 repoName: "b",
325 branchName: "y",
326 commitSha: "def",
327 });
328 const longError = "boom\n".repeat(10_000);
329 await markPreviewFailed(row!.id, longError);
330 const after = await getPreviewForBranch(repoId, "y");
331 expect(after!.status).toBe("failed");
332 expect(after!.errorMessage).not.toBeNull();
333 expect(after!.errorMessage!.length).toBeLessThanOrEqual(2_000);
334 });
335
336 it("expireOldPreviews transitions ready rows past expires_at to 'expired'", async () => {
337 ({ userId, repoId } = await seedRepo());
338
339 const row = await enqueuePreviewBuild({
340 repositoryId: repoId,
341 ownerName: "a",
342 repoName: "b",
343 branchName: "stale",
344 commitSha: "old",
345 });
346 expect(row).not.toBeNull();
347 await markPreviewReady(row!.id);
348
349 // Force expires_at into the past.
350 await db
351 .update(branchPreviews)
352 .set({ expiresAt: new Date(Date.now() - 60_000) })
353 .where(eq(branchPreviews.id, row!.id));
354
355 const flipped = await expireOldPreviews();
356 expect(flipped).toBeGreaterThanOrEqual(1);
357
358 const after = await getPreviewForBranch(repoId, "stale");
359 expect(after!.status).toBe("expired");
360 });
361
362 it("expireOldPreviews leaves fresh rows alone", async () => {
363 ({ userId, repoId } = await seedRepo());
364
365 const row = await enqueuePreviewBuild({
366 repositoryId: repoId,
367 ownerName: "a",
368 repoName: "b",
369 branchName: "fresh",
370 commitSha: "new",
371 });
372 expect(row).not.toBeNull();
373 await markPreviewReady(row!.id);
374
375 // Default expiresAt is now+24h → expire pass should ignore it.
376 await expireOldPreviews();
377 const after = await getPreviewForBranch(repoId, "fresh");
378 expect(after!.status).toBe("ready");
379 });
380
381 it("listPreviewsForRepo orders by build_started_at descending", async () => {
382 ({ userId, repoId } = await seedRepo());
383
384 const oldNow = () => new Date(Date.now() - 60_000);
385 const newNow = () => new Date();
386
387 await enqueuePreviewBuild({
388 repositoryId: repoId,
389 ownerName: "a",
390 repoName: "b",
391 branchName: "old-branch",
392 commitSha: "1",
393 now: oldNow,
394 });
395 await enqueuePreviewBuild({
396 repositoryId: repoId,
397 ownerName: "a",
398 repoName: "b",
399 branchName: "new-branch",
400 commitSha: "2",
401 now: newNow,
402 });
403
404 const list = await listPreviewsForRepo(repoId);
405 expect(list.length).toBe(2);
406 expect(list[0]!.branchName).toBe("new-branch");
407 expect(list[1]!.branchName).toBe("old-branch");
408 });
409});