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

playground.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.

playground.test.tsBlame809 lines · 1 contributor
cd4f63bTest User1/**
2 * Block Q3 — Anonymous playground tests.
3 *
4 * Strategy mirrors `account-deletion.test.ts`:
5 * - Spread-from-real `../db` mock so other test files keep working;
6 * - In-memory `users` / `sessions` / `repositories` / `issues` /
7 * `labels` / `audit_log` tables;
8 * - Real `audit()` + real `email-verification.startEmailVerification`
9 * run against our stub (both swallow DB errors);
10 * - `initBareRepo` + bare-git plumbing are stubbed at the
11 * `../git/repository` module boundary so a clean test directory
12 * doesn't need git installed (and so the suite is hermetic).
13 *
14 * Bun's `mock.module(...)` is process-global; we `mock.module("../db", _real_db)`
15 * in `afterAll` so downstream files see the real binding again.
16 */
17
18import {
19 describe,
20 it,
21 expect,
22 mock,
23 beforeEach,
24 afterAll,
25} from "bun:test";
26
27// Point the real `initBareRepo` at a hermetic per-process tmp dir so
28// the test doesn't litter `./repos/` with `guest-*` subdirs. Set
29// BEFORE importing `../db` (and downstream `../lib/playground`).
30process.env.GIT_REPOS_PATH = `/tmp/playground-test-${process.pid}-${Date.now()}`;
31
32const _real_db = await import("../db");
33const _real_notify = await import("../lib/notify");
34const _real_email_verification = await import("../lib/email-verification");
35
36// ---------------------------------------------------------------------------
37// Fake DB state
38// ---------------------------------------------------------------------------
39
40interface FakeUser {
41 id: string;
42 username: string;
43 email: string;
44 passwordHash: string;
45 isPlayground: boolean;
46 playgroundExpiresAt: Date | null;
47 emailVerifiedAt: Date | null;
48 [k: string]: any;
49}
50interface FakeSession {
51 id: string;
52 userId: string;
53 token: string;
54 expiresAt: Date;
55}
56interface FakeRepo {
57 id: string;
58 name: string;
59 ownerId: string;
60 description: string | null;
61 isPrivate: boolean;
62 defaultBranch: string;
63 diskPath: string;
64}
65interface FakeIssue {
66 id: string;
67 repositoryId: string;
68 authorId: string;
69 title: string;
70 body: string | null;
71 state: string;
72}
73interface FakeLabel {
74 id: string;
75 repositoryId: string;
76 name: string;
77 color: string;
78 description: string | null;
79}
80interface FakeIssueLabel {
81 id: string;
82 issueId: string;
83 labelId: string;
84}
85
86const _state = {
87 users: [] as FakeUser[],
88 sessions: [] as FakeSession[],
89 repositories: [] as FakeRepo[],
90 issues: [] as FakeIssue[],
91 labels: [] as FakeLabel[],
92 issueLabels: [] as FakeIssueLabel[],
93 auditInserts: [] as any[],
94};
95
96function resetState() {
97 _state.users = [];
98 _state.sessions = [];
99 _state.repositories = [];
100 _state.issues = [];
101 _state.labels = [];
102 _state.issueLabels = [];
103 _state.auditInserts = [];
104}
105
106// The columns we discriminate on for table identity. Test-friendly only.
107function tableName(t: any): string {
108 if (!t || typeof t !== "object") return "?";
109 if ("isPlayground" in t && "passwordHash" in t) return "users";
110 if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions";
111 if ("ownerId" in t && "diskPath" in t) return "repositories";
112 if ("state" in t && "repositoryId" in t && "title" in t) return "issues";
113 if ("color" in t && "repositoryId" in t && "name" in t) return "labels";
114 if ("issueId" in t && "labelId" in t) return "issue_labels";
115 if ("action" in t && "targetType" in t) return "audit_log";
116 return "?";
117}
118
119// Per-query where predicate. Each `select()` resets it; subsequent
120// `where(...)` calls install it; subsequent `limit()` / await consumes
121// it. The predicate is built by interpreting drizzle's `eq(col, val)`
122// expression's exposed `queryChunks` (a public-ish field that holds
123// SQL fragments + column refs + value literals in order). Good enough
124// for the simple where clauses our code emits.
125let _whereFn: ((row: any) => boolean) | null = null;
126function clearWhereFn() { _whereFn = null; }
127
128function predicateFromExpr(expr: any): ((row: any) => boolean) | null {
129 if (!expr || typeof expr !== "object") return null;
130 const chunks: any[] = expr.queryChunks;
131 if (!Array.isArray(chunks)) return null;
132
133 // Detect a "leaf" comparison: chunks = [SQL[""], Column, SQL[op], (value?), SQL[""]]
134 let colChunk: any = null;
135 let opStr = "";
136 let valChunk: any = undefined;
137 for (let i = 0; i < chunks.length; i++) {
138 const c = chunks[i];
139 if (c && typeof c === "object" && "name" in c && c.name && !c.queryChunks) {
140 colChunk = c;
141 // op is the next sql chunk
142 const op = chunks[i + 1];
143 if (op && Array.isArray((op as any).value)) {
144 opStr = String((op as any).value[0] || "").trim().toLowerCase();
145 }
146 // value (for binary ops) is the chunk after the op
147 if (opStr !== "is not null") {
148 valChunk = chunks[i + 2];
149 }
150 break;
151 }
152 }
153
154 if (colChunk) {
155 const camel = String(colChunk.name).replace(/_([a-z])/g, (_, c) => c.toUpperCase());
156 if (opStr === "is not null") {
157 return (row: any) => row[camel] !== null && row[camel] !== undefined;
158 }
159 // Drizzle wraps parameter values in a Param object whose plain
160 // .value field exposes the raw JS value. SQL-chunk objects ALSO
161 // expose .value (an array of SQL fragments) — so unwrap only when
162 // value is not an array.
163 const rawVal =
164 valChunk && typeof valChunk === "object" && "value" in valChunk && !Array.isArray((valChunk as any).value)
165 ? (valChunk as any).value
166 : valChunk;
167 if (opStr === "<") {
168 return (row: any) =>
169 row[camel] !== null && row[camel] !== undefined && row[camel] < rawVal;
170 }
171 if (opStr === ">") {
172 return (row: any) =>
173 row[camel] !== null && row[camel] !== undefined && row[camel] > rawVal;
174 }
175 // Default: equality (handles "=").
176 return (row: any) => {
177 const v = row[camel];
178 if (v instanceof Date && rawVal instanceof Date) {
179 return v.getTime() === rawVal.getTime();
180 }
181 return v === rawVal;
182 };
183 }
184
185 // Compound expression — recurse over nested EXPR chunks and AND them
186 // together. Drizzle's `and()` wraps its operands as EXPR queryChunks
187 // with `" and "` SQL separators; `or()` similarly.
188 const sub: Array<(row: any) => boolean> = [];
189 let connector: "and" | "or" = "and";
190 for (const c of chunks) {
191 if (c && typeof c === "object" && Array.isArray((c as any).value)) {
192 const s = String((c as any).value[0] || "").trim().toLowerCase();
193 if (s === "or") connector = "or";
194 }
195 if (c && typeof c === "object" && Array.isArray(c.queryChunks)) {
196 const fn = predicateFromExpr(c);
197 if (fn) sub.push(fn);
198 }
199 }
200 if (sub.length === 0) return null;
201 if (connector === "or") {
202 return (row: any) => sub.some((p) => p(row));
203 }
204 return (row: any) => sub.every((p) => p(row));
205}
206
207function predicateFromChunks(chunks: any[]): ((row: any) => boolean) | null {
208 return predicateFromExpr({ queryChunks: chunks });
209}
210
211function rowsForTable(table: string): any[] {
212 switch (table) {
213 case "users": return _state.users;
214 case "sessions": return _state.sessions;
215 case "repositories": return _state.repositories;
216 case "issues": return _state.issues;
217 case "labels": return _state.labels;
218 case "issue_labels": return _state.issueLabels;
219 default: return [];
220 }
221}
222
223let _lastFromTable = "?";
224
225function collectSelect(cap?: number): any[] {
226 let rows = rowsForTable(_lastFromTable);
227 if (_whereFn) rows = rows.filter(_whereFn);
228 clearWhereFn();
229 if (cap !== undefined) rows = rows.slice(0, cap);
230 return rows;
231}
232
233function makeSelectChain(): any {
234 const chain: any = {
235 limit: (cap?: number) => Promise.resolve(collectSelect(cap)),
236 offset: () => chain,
237 orderBy: () => chain,
238 then: (resolve: (v: any) => void) => resolve(collectSelect()),
239 };
240 return new Proxy(chain, {
241 get(target, prop) {
242 if (prop in target) return (target as any)[prop];
243 return (...args: any[]) => {
244 if (prop === "from" && args[0]) _lastFromTable = tableName(args[0]);
245 if (prop === "where" && args[0]) {
246 const expr = args[0];
247 if (expr && expr.queryChunks) {
248 const fn = predicateFromChunks(expr.queryChunks);
249 if (fn) _whereFn = fn;
250 }
251 }
252 return proxy;
253 };
254 },
255 });
256}
257const proxy = makeSelectChain();
258
259function makeUpdateChain(table: any) {
260 const name = tableName(table);
261 return {
262 set: (vals: any) => ({
263 where: (expr: any) => {
264 let fn: ((row: any) => boolean) | null = null;
265 if (expr && expr.queryChunks) {
266 fn = predicateFromChunks(expr.queryChunks);
267 }
268 return {
269 returning: () => {
270 const rows = rowsForTable(name);
271 const matched = fn ? rows.filter(fn) : rows;
272 for (const r of matched) Object.assign(r, vals);
273 return Promise.resolve(
274 matched.map((r) => ({
275 id: r.id,
276 username: r.username,
277 email: r.email,
278 }))
279 );
280 },
281 then: (resolve: (v: any) => void) => {
282 const rows = rowsForTable(name);
283 const matched = fn ? rows.filter(fn) : rows;
284 for (const r of matched) Object.assign(r, vals);
285 resolve(undefined);
286 },
287 };
288 },
289 }),
290 };
291}
292
293function makeDeleteChain(table: any) {
294 const name = tableName(table);
295 return {
296 where: (expr: any) => {
297 let fn: ((row: any) => boolean) | null = null;
298 if (expr && expr.queryChunks) {
299 fn = predicateFromChunks(expr.queryChunks);
300 }
301 const apply = () => {
302 const rows = rowsForTable(name);
303 const matched = fn ? rows.filter(fn) : rows;
304 const ids = matched.map((r) => r.id);
305 if (name === "users") {
306 _state.users = _state.users.filter((u) => !ids.includes(u.id));
307 // CASCADE
308 _state.sessions = _state.sessions.filter(
309 (s) => !ids.includes(s.userId)
310 );
311 _state.repositories = _state.repositories.filter(
312 (r) => !ids.includes(r.ownerId)
313 );
314 } else if (name === "sessions") {
315 _state.sessions = _state.sessions.filter(
316 (s) => !ids.includes(s.id)
317 );
318 }
319 return ids;
320 };
321 return {
322 returning: () => {
323 const ids = apply();
324 return Promise.resolve(ids.map((id) => ({ id })));
325 },
326 then: (resolve: (v: any) => void) => {
327 apply();
328 resolve(undefined);
329 },
330 };
331 },
332 };
333}
334
335let _idCounter = 0;
336function nextId(): string {
337 _idCounter += 1;
338 return `id-${_idCounter.toString(16).padStart(12, "0")}-test`;
339}
340
341function makeInsertChain(table: any) {
342 const name = tableName(table);
343 return {
344 values: (vals: any) => {
345 let inserted: any = null;
346 if (name === "audit_log") {
347 _state.auditInserts.push(vals);
348 } else if (name === "users") {
349 const u: FakeUser = {
350 id: vals.id || nextId(),
351 username: vals.username,
352 email: vals.email,
353 passwordHash: vals.passwordHash,
354 isPlayground: !!vals.isPlayground,
355 playgroundExpiresAt: vals.playgroundExpiresAt ?? null,
356 emailVerifiedAt: vals.emailVerifiedAt ?? null,
357 };
358 _state.users.push(u);
359 inserted = { id: u.id, username: u.username, email: u.email };
360 } else if (name === "sessions") {
361 const s: FakeSession = {
362 id: nextId(),
363 userId: vals.userId,
364 token: vals.token,
365 expiresAt: vals.expiresAt,
366 };
367 _state.sessions.push(s);
368 inserted = { id: s.id };
369 } else if (name === "repositories") {
370 const r: FakeRepo = {
371 id: nextId(),
372 name: vals.name,
373 ownerId: vals.ownerId,
374 description: vals.description ?? null,
375 isPrivate: !!vals.isPrivate,
376 defaultBranch: vals.defaultBranch || "main",
377 diskPath: vals.diskPath || "",
378 };
379 _state.repositories.push(r);
380 inserted = { id: r.id };
381 } else if (name === "issues") {
382 const i: FakeIssue = {
383 id: nextId(),
384 repositoryId: vals.repositoryId,
385 authorId: vals.authorId,
386 title: vals.title,
387 body: vals.body ?? null,
388 state: vals.state || "open",
389 };
390 _state.issues.push(i);
391 inserted = { id: i.id };
392 } else if (name === "labels") {
393 const l: FakeLabel = {
394 id: nextId(),
395 repositoryId: vals.repositoryId,
396 name: vals.name,
397 color: vals.color || "#888",
398 description: vals.description ?? null,
399 };
400 _state.labels.push(l);
401 inserted = { id: l.id };
402 } else if (name === "issue_labels") {
403 const il: FakeIssueLabel = {
404 id: nextId(),
405 issueId: vals.issueId,
406 labelId: vals.labelId,
407 };
408 _state.issueLabels.push(il);
409 inserted = { id: il.id };
410 }
411 return {
412 returning: () =>
413 Promise.resolve(inserted ? [inserted] : []),
414 onConflictDoNothing: () =>
415 Promise.resolve(inserted ? [inserted] : []),
416 then: (resolve: (v: any) => void) => resolve(undefined),
417 };
418 },
419 };
420}
421
422const _fakeDb = {
423 db: {
424 select: (_cols?: any) => {
425 _lastFromTable = "?";
426 return makeSelectChain();
427 },
428 insert: (t: any) => makeInsertChain(t),
429 update: (t: any) => makeUpdateChain(t),
430 delete: (t: any) => makeDeleteChain(t),
431 },
432};
433
434// ---------------------------------------------------------------------------
435// Note: we deliberately do NOT mock `../git/repository` or
436// `../lib/repo-bootstrap`. The lib wraps every disk + git subprocess
437// in try/catch, so the real bindings degrade to a logged error when
438// the fake repo dir doesn't exist — and we sidestep mock pollution
439// against git-repository.test.ts. The DB mock still captures repo +
440// label + issue inserts so assertions on `_state.repositories` still
441// hold.
442
443// Track verification-email "sends" via the lib's first-party
444// `__setEmailForTests` seam. The real `startEmailVerification` still
445// runs end-to-end (write token row → render email → call sender);
446// we intercept only the outbound email so we can record the target
447// address without coupling to the email module's internals. We
448// restore the previous sender in `afterAll` so other test files'
449// recorders aren't overwritten.
450let _verificationCalls: Array<{ email: string }> = [];
451const _origSender = _real_email_verification.__setEmailForTests(
452 async (msg: any) => {
453 _verificationCalls.push({ email: String(msg.to) });
cab3dc9Test User454 return { ok: true, provider: "log" as const };
cd4f63bTest User455 }
456);
457
458// ---------------------------------------------------------------------------
459// Mock module wiring
460// ---------------------------------------------------------------------------
461
462function _reinstallMocks() {
463 mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
464 mock.module("../lib/notify", () => _real_notify);
465}
466_reinstallMocks();
467
468afterAll(async () => {
469 resetState();
470 clearWhereFn();
471 _lastFromTable = "?";
472 mock.module("../db", () => _real_db);
473 mock.module("../lib/notify", () => _real_notify);
474 // Restore the prior email sender so other tests' recorders survive.
475 _real_email_verification.__setEmailForTests(_origSender);
476 // Best-effort cleanup of the tmp git-repos dir.
477 try {
478 const { rm } = await import("node:fs/promises");
479 await rm(process.env.GIT_REPOS_PATH!, { recursive: true, force: true });
480 } catch {
481 /* ignore */
482 }
483});
484
485// Dynamic import AFTER mock.module so the lib's bindings resolve to
486// our fakes.
487const _pg = await import("../lib/playground");
488const createPlaygroundAccount = _pg.createPlaygroundAccount;
489const claimPlaygroundAccount = _pg.claimPlaygroundAccount;
490const purgeExpiredPlaygroundAccounts = _pg.purgeExpiredPlaygroundAccounts;
491const isPlaygroundAccount = _pg.isPlaygroundAccount;
492const PLAYGROUND_TTL_MS = _pg.PLAYGROUND_TTL_MS;
493const SANDBOX_REPO_NAME = _pg.SANDBOX_REPO_NAME;
494
495beforeEach(() => {
496 _reinstallMocks();
497 resetState();
498 clearWhereFn();
499 _lastFromTable = "?";
500 _verificationCalls = [];
501});
502
503// ---------------------------------------------------------------------------
504// createPlaygroundAccount
505// ---------------------------------------------------------------------------
506
507describe("createPlaygroundAccount", () => {
508 it("mints a playground user + 24h session + sandbox repo", async () => {
509 const before = Date.now();
510 const result = await createPlaygroundAccount();
511 const after = Date.now();
512
513 expect(result.user.username).toMatch(/^guest-[0-9a-f]{8}$/);
514 expect(result.user.email).toBe(
515 `${result.user.username}@playground.gluecron.local`
516 );
517 expect(result.sessionToken).toMatch(/^[0-9a-f]{64}$/);
518 expect(result.sampleRepoFullName).toBe(
519 `${result.user.username}/${SANDBOX_REPO_NAME}`
520 );
521
522 // User row
523 const u = _state.users.find((r) => r.id === result.user.id);
524 expect(u).toBeDefined();
525 expect(u!.isPlayground).toBe(true);
526 expect(u!.playgroundExpiresAt).toBeInstanceOf(Date);
527 const expMs = u!.playgroundExpiresAt!.getTime();
528 expect(expMs).toBeGreaterThanOrEqual(before + PLAYGROUND_TTL_MS - 1000);
529 expect(expMs).toBeLessThanOrEqual(after + PLAYGROUND_TTL_MS + 1000);
530 expect(u!.emailVerifiedAt).toBeInstanceOf(Date);
531
532 // Session row
533 const s = _state.sessions.find((r) => r.token === result.sessionToken);
534 expect(s).toBeDefined();
535 expect(s!.userId).toBe(result.user.id);
536
537 // Sandbox repo
538 const r = _state.repositories.find((r) => r.ownerId === result.user.id);
539 expect(r).toBeDefined();
540 expect(r!.name).toBe(SANDBOX_REPO_NAME);
541 expect(r!.isPrivate).toBe(false);
542
543 // Sample issues
544 const repoIssues = _state.issues.filter(
545 (i) => i.repositoryId === r!.id
546 );
547 expect(repoIssues.length).toBeGreaterThanOrEqual(2);
548 });
549
550 it("marks email_verified_at so the verify-email banner is suppressed", async () => {
551 const result = await createPlaygroundAccount();
552 const u = _state.users.find((r) => r.id === result.user.id);
553 expect(u!.emailVerifiedAt).not.toBeNull();
554 });
555});
556
557// ---------------------------------------------------------------------------
558// claimPlaygroundAccount
559// ---------------------------------------------------------------------------
560
561describe("claimPlaygroundAccount", () => {
562 async function makePlayground(): Promise<string> {
563 const r = await createPlaygroundAccount();
564 return r.user.id;
565 }
566
567 it("clears playground flags, sets new email + password, fires verification", async () => {
568 const uid = await makePlayground();
569
570 const ok = await claimPlaygroundAccount(uid, {
571 email: "real@example.com",
572 password: "supersecret",
573 });
574
575 expect(ok.ok).toBe(true);
576 const u = _state.users.find((r) => r.id === uid);
577 expect(u!.isPlayground).toBe(false);
578 expect(u!.playgroundExpiresAt).toBeNull();
579 expect(u!.email).toBe("real@example.com");
580 expect(u!.emailVerifiedAt).toBeNull();
581 expect(u!.passwordHash).not.toBe("");
582
583 // Allow the fire-and-forget Promise to schedule. We don't actually
584 // need to await it because the fake just pushes synchronously
585 // inside a Promise body — but flushing the microtask queue lets
586 // the call settle.
587 await Promise.resolve();
588 await Promise.resolve();
589 expect(_verificationCalls.length).toBeGreaterThanOrEqual(1);
590 expect(_verificationCalls[0].email).toBe("real@example.com");
591 });
592
593 it("rejects when email is already taken by another user", async () => {
594 // Seed a real user with the email.
595 _state.users.push({
596 id: "preexisting",
597 username: "alice",
598 email: "taken@example.com",
599 passwordHash: "x",
600 isPlayground: false,
601 playgroundExpiresAt: null,
602 emailVerifiedAt: new Date(),
603 });
604
605 const uid = await makePlayground();
606
607 const result = await claimPlaygroundAccount(uid, {
608 email: "taken@example.com",
609 password: "supersecret",
610 });
611 expect(result.ok).toBe(false);
612 expect(result.reason).toBe("email_taken");
613 });
614
615 it("rejects invalid email + short password", async () => {
616 const uid = await makePlayground();
617
618 const r1 = await claimPlaygroundAccount(uid, {
619 email: "nope",
620 password: "supersecret",
621 });
622 expect(r1.ok).toBe(false);
623 expect(r1.reason).toBe("invalid_email");
624
625 const r2 = await claimPlaygroundAccount(uid, {
626 email: "real@example.com",
627 password: "short",
628 });
629 expect(r2.ok).toBe(false);
630 expect(r2.reason).toBe("password_too_short");
631 });
632
633 it("rejects when invoked on a non-playground user", async () => {
634 _state.users.push({
635 id: "real-user",
636 username: "bob",
637 email: "bob@example.com",
638 passwordHash: "x",
639 isPlayground: false,
640 playgroundExpiresAt: null,
641 emailVerifiedAt: new Date(),
642 });
643 const r = await claimPlaygroundAccount("real-user", {
644 email: "new@example.com",
645 password: "supersecret",
646 });
647 expect(r.ok).toBe(false);
648 expect(r.reason).toBe("not_a_playground_account");
649 });
650});
651
652// ---------------------------------------------------------------------------
653// purgeExpiredPlaygroundAccounts
654// ---------------------------------------------------------------------------
655
656describe("purgeExpiredPlaygroundAccounts", () => {
657 it("hard-deletes expired playground users, leaves unexpired alone", async () => {
658 const past = new Date(Date.now() - 60_000);
659 const future = new Date(Date.now() + 60_000);
660
661 const expiredId = "ex-1";
662 const liveId = "ex-2";
663 _state.users.push(
664 {
665 id: expiredId,
666 username: "guest-deadbeef",
667 email: "guest-deadbeef@playground.gluecron.local",
668 passwordHash: "x",
669 isPlayground: true,
670 playgroundExpiresAt: past,
671 emailVerifiedAt: new Date(),
672 },
673 {
674 id: liveId,
675 username: "guest-cafebabe",
676 email: "guest-cafebabe@playground.gluecron.local",
677 passwordHash: "x",
678 isPlayground: true,
679 playgroundExpiresAt: future,
680 emailVerifiedAt: new Date(),
681 }
682 );
683
684 const result = await purgeExpiredPlaygroundAccounts({ now: new Date() });
685
686 expect(result.errors).toBe(0);
687 expect(_state.users.find((u) => u.id === expiredId)).toBeUndefined();
688 expect(_state.users.find((u) => u.id === liveId)).toBeDefined();
689 expect(result.purged).toBe(1);
690 });
691
692 it("caps deletions at the supplied cap and never throws", async () => {
693 const past = new Date(Date.now() - 60_000);
694 for (let i = 0; i < 5; i++) {
695 _state.users.push({
696 id: `g-${i}`,
697 username: `guest-${i.toString(16).padStart(8, "0")}`,
698 email: `g${i}@playground.gluecron.local`,
699 passwordHash: "x",
700 isPlayground: true,
701 playgroundExpiresAt: past,
702 emailVerifiedAt: new Date(),
703 });
704 }
705 const result = await purgeExpiredPlaygroundAccounts({ cap: 2 });
706 expect(result.purged).toBeLessThanOrEqual(2);
707 expect(result.errors).toBe(0);
708 });
709
710 it("survives DB outage with errors > 0 and never throws", async () => {
711 const orig = _fakeDb.db.select;
712 _fakeDb.db.select = (() => {
713 throw new Error("synthetic outage");
714 }) as any;
715 try {
716 const result = await purgeExpiredPlaygroundAccounts({ now: new Date() });
717 expect(result.purged).toBe(0);
718 expect(result.errors).toBeGreaterThan(0);
719 } finally {
720 _fakeDb.db.select = orig;
721 }
722 });
723});
724
725// ---------------------------------------------------------------------------
726// isPlaygroundAccount (pure helper)
727// ---------------------------------------------------------------------------
728
729describe("isPlaygroundAccount", () => {
730 it("returns true only when isPlayground is exactly true", () => {
731 expect(isPlaygroundAccount({ isPlayground: true })).toBe(true);
732 expect(isPlaygroundAccount({ isPlayground: false })).toBe(false);
733 expect(isPlaygroundAccount({ isPlayground: null })).toBe(false);
734 expect(isPlaygroundAccount({} as any)).toBe(false);
735 });
736});
737
738// ---------------------------------------------------------------------------
739// Route + autopilot wiring assertions (source-string checks).
740// ---------------------------------------------------------------------------
741
742describe("route wiring (source-string assertions)", () => {
743 let playgroundSrc = "";
744 let autopilotSrc = "";
745 let layoutSrc = "";
746 let landingSrc = "";
747 let appSrc = "";
748
749 beforeEach(async () => {
750 if (!playgroundSrc) {
751 const fs = await import("node:fs/promises");
752 playgroundSrc = await fs.readFile(
753 new URL("../routes/playground.tsx", import.meta.url),
754 "utf8"
755 );
756 autopilotSrc = await fs.readFile(
757 new URL("../lib/autopilot.ts", import.meta.url),
758 "utf8"
759 );
760 layoutSrc = await fs.readFile(
761 new URL("../views/layout.tsx", import.meta.url),
762 "utf8"
763 );
764 landingSrc = await fs.readFile(
765 new URL("../views/landing.tsx", import.meta.url),
766 "utf8"
767 );
768 appSrc = await fs.readFile(
769 new URL("../app.tsx", import.meta.url),
770 "utf8"
771 );
772 }
773 });
774
775 it("playground.tsx registers GET /play and POST /play", () => {
776 expect(playgroundSrc).toMatch(/\.get\(\s*["']\/play["']/);
777 expect(playgroundSrc).toMatch(/\.post\(\s*["']\/play["']/);
778 });
779
780 it("playground.tsx registers GET + POST /play/claim and requireAuth", () => {
781 expect(playgroundSrc).toMatch(/\.get\(\s*["']\/play\/claim["']\s*,\s*requireAuth/);
782 expect(playgroundSrc).toMatch(/\.post\(\s*["']\/play\/claim["']\s*,\s*requireAuth/);
783 });
784
785 it("POST /play is rate-limited at 3/min via the shared middleware", () => {
786 expect(playgroundSrc).toContain('rateLimit(3, 60_000');
787 });
788
789 it("autopilot.ts wires the playground-purge task", () => {
790 expect(autopilotSrc).toContain('name: "playground-purge"');
791 expect(autopilotSrc).toContain("purgeExpiredPlaygroundAccounts");
792 });
793
794 it("layout.tsx renders the playground banner gated on user.isPlayground", () => {
795 expect(layoutSrc).toContain("playground-banner");
796 expect(layoutSrc).toContain("isPlayground");
797 expect(layoutSrc).toContain("Save your work");
798 });
799
800 it("landing.tsx adds the tertiary /play CTA", () => {
801 expect(landingSrc).toContain('href="/play"');
802 expect(landingSrc).toContain("without signing up");
803 });
804
805 it("app.tsx mounts the playground routes", () => {
806 expect(appSrc).toContain("playgroundRoutes");
807 expect(appSrc).toContain('"./routes/playground"');
808 });
809});