CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
email-verification.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.
| c63b860 | 1 | /** |
| 2 | * Block P2 — email verification + welcome email tests. | |
| 3 | * | |
| 4 | * We stub `../db` via `mock.module` (K1 spread-from-real pattern) so the | |
| 5 | * lib's drizzle calls land on an in-memory fake instead of Neon. The email | |
| 6 | * sender is swapped out via the lib's `__setEmailForTests` test seam. | |
| 7 | * | |
| 8 | * Bun 1.3's `bun test` shares a single module registry across every test | |
| 9 | * file in a run and `mock.restore()` does NOT un-mock `mock.module(...)` | |
| 10 | * registrations. To stay neighbourly: | |
| 11 | * - we capture the real `../db` module before overriding so unrelated | |
| 12 | * downstream tests can fall back to it via the spread; | |
| 13 | * - we re-install our mock in `beforeEach` so an earlier test file's | |
| 14 | * `mock.module("../db", ...)` doesn't shadow ours mid-run; | |
| 15 | * - we restore the real DB module in `afterAll` so the next file's | |
| 16 | * suite sees the prod contract again. | |
| 17 | */ | |
| 18 | ||
| 19 | import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; | |
| 20 | import { Hono } from "hono"; | |
| 21 | import { createHash } from "node:crypto"; | |
| 22 | ||
| 23 | // Capture the real `../db` module so we can spread-from-real and so we | |
| 24 | // can restore it in `afterAll` for downstream test files. | |
| 25 | const _real_db = await import("../db"); | |
| 26 | ||
| 27 | // --------------------------------------------------------------------------- | |
| 28 | // Fake DB — narrowly scoped to what `email-verification.ts` + the route call. | |
| 29 | // --------------------------------------------------------------------------- | |
| 30 | ||
| 31 | interface FakeUser { | |
| 32 | id: string; | |
| 33 | username: string; | |
| 34 | email: string; | |
| 35 | emailVerifiedAt: Date | null; | |
| 36 | } | |
| 37 | ||
| 38 | interface FakeToken { | |
| 39 | id: string; | |
| 40 | userId: string; | |
| 41 | email: string; | |
| 42 | tokenHash: string; | |
| 43 | expiresAt: Date; | |
| 44 | usedAt: Date | null; | |
| 45 | createdAt: Date; | |
| 46 | } | |
| 47 | ||
| 48 | const _state = { | |
| 49 | users: [] as FakeUser[], | |
| 50 | tokens: [] as FakeToken[], | |
| 51 | _lastFromTable: "" as "users" | "tokens" | "", | |
| 52 | }; | |
| 53 | ||
| 54 | function resetState() { | |
| 55 | _state.users = []; | |
| 56 | _state.tokens = []; | |
| 57 | _state._lastFromTable = ""; | |
| 58 | } | |
| 59 | ||
| 60 | function tableName(t: any): "users" | "tokens" | "" { | |
| 61 | if (!t || typeof t !== "object") return ""; | |
| 62 | if ("emailVerifiedAt" in t && "passwordHash" in t) return "users"; | |
| 63 | if ("emailVerifiedAt" in t && "username" in t) return "users"; | |
| 64 | if ("tokenHash" in t && "expiresAt" in t && "email" in t) return "tokens"; | |
| 65 | return ""; | |
| 66 | } | |
| 67 | ||
| 68 | let _nextWhereFilter: ((row: any) => boolean) | null = null; | |
| 69 | function setNextWhereFilter(fn: (row: any) => boolean) { | |
| 70 | _nextWhereFilter = fn; | |
| 71 | } | |
| 72 | ||
| 73 | // The drizzle chain is awaited two ways in code we proxy through: | |
| 74 | // await db.select(...).from(...).where(...).limit(N) | |
| 75 | // await db.select(...).from(...).where(...) // no limit | |
| 76 | // We support both by exposing both `.limit()` AND a thenable on the chain | |
| 77 | // itself. The thenable yields the same shape `.limit(N)` would, with | |
| 78 | // N=Infinity (no cap) — that's the convention drizzle uses for awaited | |
| 79 | // chains without an explicit limit. | |
| 80 | function resolveSelect(n: number = Infinity): any[] { | |
| 81 | const rows = | |
| 82 | _state._lastFromTable === "users" | |
| 83 | ? _state.users | |
| 84 | : _state._lastFromTable === "tokens" | |
| 85 | ? _state.tokens | |
| 86 | : []; | |
| 87 | const filtered = _nextWhereFilter ? rows.filter(_nextWhereFilter) : rows; | |
| 88 | _nextWhereFilter = null; | |
| 89 | return filtered.slice(0, n); | |
| 90 | } | |
| 91 | ||
| 92 | // We model the drizzle chain as a function-target Proxy that: | |
| 93 | // - returns itself for any chained method (`.from`, `.where`, | |
| 94 | // `.orderBy`, `.leftJoin`, `.innerJoin`, etc.) so chain calls compose; | |
| 95 | // - exposes a `then` that resolves to the current row array, making | |
| 96 | // `await selectChain` work without an explicit `.limit()`; | |
| 97 | // - exposes `.limit(N)` for callers that DO cap the row count. | |
| 98 | // | |
| 99 | // The proxy is built on a function target rather than `{}` so the | |
| 100 | // resulting object reads as a thenable to V8's await machinery (some | |
| 101 | // edge cases with `{}` + `then` were observed where the runtime read | |
| 102 | // `then` through a different path and ignored it). Function targets | |
| 103 | // always present a clean own-property `then` slot. | |
| 104 | function makeSelectChain(): any { | |
| 105 | function chainFn() {} | |
| 106 | const handler: ProxyHandler<any> = { | |
| 107 | get(_t, prop, receiver) { | |
| 108 | if (prop === "then") { | |
| 109 | // Standard thenable contract: `(resolve, reject) => …`. We | |
| 110 | // resolve synchronously since the fake never actually waits on | |
| 111 | // anything — V8 promotes us into a microtask anyway. | |
| 112 | return (resolve: (v: any[]) => void) => resolve(resolveSelect()); | |
| 113 | } | |
| 114 | if (prop === "limit") { | |
| 115 | return (n: number) => Promise.resolve(resolveSelect(n)); | |
| 116 | } | |
| 117 | if (prop === "from") { | |
| 118 | return (table: any) => { | |
| 119 | _state._lastFromTable = tableName(table); | |
| 120 | return receiver; | |
| 121 | }; | |
| 122 | } | |
| 123 | // Any other method (where / orderBy / groupBy / leftJoin / innerJoin | |
| 124 | // / rightJoin / etc.) returns the same proxy so we keep chaining. | |
| 125 | // Symbols / unknown non-string keys pass through as undefined. | |
| 126 | if (typeof prop !== "string") return undefined; | |
| 127 | return () => receiver; | |
| 128 | }, | |
| 129 | }; | |
| 130 | return new Proxy(chainFn, handler); | |
| 131 | } | |
| 132 | ||
| 133 | const selectChain: any = makeSelectChain(); | |
| 134 | ||
| 135 | const insertChain: any = { | |
| 136 | values(v: any) { | |
| 137 | if (_state._lastFromTable === "tokens") { | |
| 138 | _state.tokens.push({ | |
| 139 | id: `tok-${_state.tokens.length + 1}`, | |
| 140 | userId: v.userId, | |
| 141 | email: v.email, | |
| 142 | tokenHash: v.tokenHash, | |
| 143 | expiresAt: | |
| 144 | v.expiresAt instanceof Date ? v.expiresAt : new Date(v.expiresAt), | |
| 145 | usedAt: null, | |
| 146 | createdAt: new Date(), | |
| 147 | }); | |
| 148 | } | |
| 149 | return Promise.resolve(); | |
| 150 | }, | |
| 151 | }; | |
| 152 | ||
| 153 | const updateChain: any = { | |
| 154 | _pendingSet: null as any, | |
| 155 | set(values: any) { | |
| 156 | updateChain._pendingSet = values; | |
| 157 | return updateChain; | |
| 158 | }, | |
| 159 | async where(_clause: any) { | |
| 160 | const target = | |
| 161 | _state._lastFromTable === "tokens" | |
| 162 | ? _state.tokens | |
| 163 | : _state._lastFromTable === "users" | |
| 164 | ? _state.users | |
| 165 | : []; | |
| 166 | const filter = _nextWhereFilter || (() => true); | |
| 167 | _nextWhereFilter = null; | |
| 168 | for (const row of target) { | |
| 169 | if (filter(row)) Object.assign(row, updateChain._pendingSet); | |
| 170 | } | |
| 171 | }, | |
| 172 | }; | |
| 173 | ||
| 174 | const _fakeDb = { | |
| 175 | db: { | |
| 176 | select: (_proj?: any) => { | |
| 177 | _state._lastFromTable = ""; | |
| 178 | return selectChain; | |
| 179 | }, | |
| 180 | insert: (t: any) => { | |
| 181 | _state._lastFromTable = tableName(t); | |
| 182 | return insertChain; | |
| 183 | }, | |
| 184 | update: (t: any) => { | |
| 185 | _state._lastFromTable = tableName(t); | |
| 186 | updateChain._pendingSet = null; | |
| 187 | return updateChain; | |
| 188 | }, | |
| 189 | delete: (_t: any) => ({ where: async () => {} }), | |
| 190 | }, | |
| 191 | getDb: () => _fakeDb.db, | |
| 192 | }; | |
| 193 | ||
| 194 | // Spread the real module first so functions like `getDb` keep working | |
| 195 | // when downstream code paths reach for them — only the names we override | |
| 196 | // take the fake. Same K1 pattern account-deletion.test.ts uses. | |
| 197 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 198 | ||
| 199 | function _reinstallDbMock() { | |
| 200 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 201 | } | |
| 202 | ||
| 203 | // --------------------------------------------------------------------------- | |
| 204 | // Email recorder — installed via the lib's test seam. | |
| 205 | // --------------------------------------------------------------------------- | |
| 206 | ||
| 207 | interface RecordedEmail { | |
| 208 | to: string; | |
| 209 | subject: string; | |
| 210 | text: string; | |
| 211 | html?: string; | |
| 212 | } | |
| 213 | const _emails: RecordedEmail[] = []; | |
| 214 | async function recordEmail(msg: RecordedEmail) { | |
| 215 | _emails.push({ ...msg }); | |
| 216 | return { ok: true as const, provider: "log" as const }; | |
| 217 | } | |
| 218 | ||
| 219 | // Load the lib AFTER mock.module so the import picks up the fake DB. | |
| 220 | const { | |
| 221 | generateVerificationToken, | |
| 222 | hashToken, | |
| 223 | startEmailVerification, | |
| 224 | consumeVerificationToken, | |
| 225 | sendWelcomeEmail, | |
| 226 | renderVerificationEmail, | |
| 227 | renderWelcomeEmail, | |
| 228 | __setEmailForTests, | |
| 229 | } = await import("../lib/email-verification"); | |
| 230 | ||
| 231 | const { default: emailVerificationRoutes, __resetResendRateLimitForTests } = | |
| 232 | await import("../routes/email-verification"); | |
| 233 | const authRoutesModule = await import("../routes/auth"); | |
| 234 | const authRoutes = authRoutesModule.default; | |
| 235 | ||
| 236 | function buildApp() { | |
| 237 | const app = new Hono(); | |
| 238 | app.route("/", emailVerificationRoutes); | |
| 239 | return app; | |
| 240 | } | |
| 241 | ||
| 242 | let _restoreEmail: ReturnType<typeof __setEmailForTests> | null = null; | |
| 243 | ||
| 244 | beforeEach(() => { | |
| 245 | _reinstallDbMock(); | |
| 246 | resetState(); | |
| 247 | _emails.length = 0; | |
| 248 | __resetResendRateLimitForTests(); | |
| 249 | _restoreEmail = __setEmailForTests(recordEmail); | |
| 250 | }); | |
| 251 | ||
| 252 | afterAll(() => { | |
| 253 | if (_restoreEmail) __setEmailForTests(_restoreEmail); | |
| 254 | resetState(); | |
| 255 | _emails.length = 0; | |
| 256 | // Restore the real DB module so downstream test files see prod semantics. | |
| 257 | mock.module("../db", () => _real_db); | |
| 258 | }); | |
| 259 | ||
| 260 | // --------------------------------------------------------------------------- | |
| 261 | // Pure helpers | |
| 262 | // --------------------------------------------------------------------------- | |
| 263 | ||
| 264 | describe("generateVerificationToken", () => { | |
| 265 | it("produces a 64-char hex plaintext and a matching sha256 hash", () => { | |
| 266 | const { plaintext, hash } = generateVerificationToken(); | |
| 267 | expect(plaintext).toMatch(/^[0-9a-f]{64}$/); | |
| 268 | expect(hash).toMatch(/^[0-9a-f]{64}$/); | |
| 269 | expect(hash).toBe(createHash("sha256").update(plaintext).digest("hex")); | |
| 270 | expect(hashToken(plaintext)).toBe(hash); | |
| 271 | }); | |
| 272 | ||
| 273 | it("returns different tokens on successive calls", () => { | |
| 274 | const a = generateVerificationToken(); | |
| 275 | const b = generateVerificationToken(); | |
| 276 | expect(a.plaintext).not.toBe(b.plaintext); | |
| 277 | expect(a.hash).not.toBe(b.hash); | |
| 278 | }); | |
| 279 | }); | |
| 280 | ||
| 281 | describe("renderVerificationEmail / renderWelcomeEmail", () => { | |
| 282 | it("verification email subject + html escape username", () => { | |
| 283 | const m = renderVerificationEmail({ | |
| 284 | username: "<bob>", | |
| 285 | link: "https://example.com/verify-email?token=abc", | |
| 286 | }); | |
| 287 | expect(m.subject).toBe("Confirm your email for Gluecron"); | |
| 288 | expect(m.text).toContain("Hi <bob>"); | |
| 289 | expect(m.html).toContain("<bob>"); | |
| 290 | expect(m.html).toContain("https://example.com/verify-email?token=abc"); | |
| 291 | }); | |
| 292 | ||
| 293 | it("welcome email mentions all four next-step links", () => { | |
| 294 | const m = renderWelcomeEmail({ username: "alice" }); | |
| 295 | expect(m.subject).toContain("Welcome to Gluecron"); | |
| 296 | expect(m.text).toContain("Welcome aboard, alice!"); | |
| 297 | expect(m.text).toContain("/new"); | |
| 298 | expect(m.text).toContain("/import"); | |
| 299 | expect(m.text).toContain("/demo"); | |
| 300 | expect(m.text).toContain("/install"); | |
| 301 | expect(m.html).toContain("Welcome aboard,"); | |
| 302 | }); | |
| 303 | }); | |
| 304 | ||
| 305 | // --------------------------------------------------------------------------- | |
| 306 | // startEmailVerification + consumeVerificationToken (DB path) | |
| 307 | // --------------------------------------------------------------------------- | |
| 308 | ||
| 309 | describe("startEmailVerification", () => { | |
| 310 | it("inserts a token row and sends a verification email", async () => { | |
| 311 | _state.users.push({ | |
| 312 | id: "u1", | |
| 313 | username: "alice", | |
| 314 | email: "alice@example.com", | |
| 315 | emailVerifiedAt: null, | |
| 316 | }); | |
| 317 | setNextWhereFilter((u: FakeUser) => u.id === "u1"); | |
| 318 | const r = await startEmailVerification("u1", "alice@example.com"); | |
| 319 | expect(r.ok).toBe(true); | |
| 320 | expect(_state.tokens.length).toBe(1); | |
| 321 | expect(_state.tokens[0].userId).toBe("u1"); | |
| 322 | expect(_state.tokens[0].email).toBe("alice@example.com"); | |
| 323 | expect(_state.tokens[0].tokenHash).toMatch(/^[0-9a-f]{64}$/); | |
| 324 | expect(_emails.length).toBe(1); | |
| 325 | expect(_emails[0].to).toBe("alice@example.com"); | |
| 326 | expect(_emails[0].subject).toBe("Confirm your email for Gluecron"); | |
| 327 | expect(_emails[0].text).toContain("alice"); | |
| 328 | }); | |
| 329 | }); | |
| 330 | ||
| 331 | describe("consumeVerificationToken", () => { | |
| 332 | it("rejects garbage / empty input", async () => { | |
| 333 | expect((await consumeVerificationToken("")).ok).toBe(false); | |
| 334 | expect( | |
| 335 | (await consumeVerificationToken("not-a-real-token")).ok | |
| 336 | ).toBe(false); | |
| 337 | }); | |
| 338 | ||
| 339 | it("happy path: marks token used + sets users.emailVerifiedAt", async () => { | |
| 340 | _state.users.push({ | |
| 341 | id: "u2", | |
| 342 | username: "bob", | |
| 343 | email: "bob@example.com", | |
| 344 | emailVerifiedAt: null, | |
| 345 | }); | |
| 346 | const { plaintext, hash } = generateVerificationToken(); | |
| 347 | _state.tokens.push({ | |
| 348 | id: "t-1", | |
| 349 | userId: "u2", | |
| 350 | email: "bob@example.com", | |
| 351 | tokenHash: hash, | |
| 352 | expiresAt: new Date(Date.now() + 1000 * 60 * 60), | |
| 353 | usedAt: null, | |
| 354 | createdAt: new Date(), | |
| 355 | }); | |
| 356 | setNextWhereFilter( | |
| 357 | (t: FakeToken) => | |
| 358 | t.tokenHash === hash && | |
| 359 | t.usedAt === null && | |
| 360 | t.expiresAt.getTime() > Date.now() | |
| 361 | ); | |
| 362 | const r = await consumeVerificationToken(plaintext); | |
| 363 | expect(r.ok).toBe(true); | |
| 364 | expect(r.userId).toBe("u2"); | |
| 365 | expect(_state.tokens[0].usedAt).toBeInstanceOf(Date); | |
| 366 | expect(_state.users[0].emailVerifiedAt).toBeInstanceOf(Date); | |
| 367 | }); | |
| 368 | ||
| 369 | it("rejects expired tokens", async () => { | |
| 370 | const { plaintext, hash } = generateVerificationToken(); | |
| 371 | _state.tokens.push({ | |
| 372 | id: "t-2", | |
| 373 | userId: "u3", | |
| 374 | email: "expired@example.com", | |
| 375 | tokenHash: hash, | |
| 376 | expiresAt: new Date(Date.now() - 1000), | |
| 377 | usedAt: null, | |
| 378 | createdAt: new Date(), | |
| 379 | }); | |
| 380 | setNextWhereFilter( | |
| 381 | (t: FakeToken) => | |
| 382 | t.tokenHash === hash && | |
| 383 | t.usedAt === null && | |
| 384 | t.expiresAt.getTime() > Date.now() | |
| 385 | ); | |
| 386 | const r = await consumeVerificationToken(plaintext); | |
| 387 | expect(r.ok).toBe(false); | |
| 388 | }); | |
| 389 | ||
| 390 | it("rejects already-used tokens", async () => { | |
| 391 | const { plaintext, hash } = generateVerificationToken(); | |
| 392 | _state.tokens.push({ | |
| 393 | id: "t-3", | |
| 394 | userId: "u4", | |
| 395 | email: "used@example.com", | |
| 396 | tokenHash: hash, | |
| 397 | expiresAt: new Date(Date.now() + 1000 * 60 * 60), | |
| 398 | usedAt: new Date(), | |
| 399 | createdAt: new Date(), | |
| 400 | }); | |
| 401 | setNextWhereFilter( | |
| 402 | (t: FakeToken) => | |
| 403 | t.tokenHash === hash && | |
| 404 | t.usedAt === null && | |
| 405 | t.expiresAt.getTime() > Date.now() | |
| 406 | ); | |
| 407 | const r = await consumeVerificationToken(plaintext); | |
| 408 | expect(r.ok).toBe(false); | |
| 409 | }); | |
| 410 | }); | |
| 411 | ||
| 412 | // --------------------------------------------------------------------------- | |
| 413 | // sendWelcomeEmail | |
| 414 | // --------------------------------------------------------------------------- | |
| 415 | ||
| 416 | describe("sendWelcomeEmail", () => { | |
| 417 | it("sends a welcome email for the resolved user", async () => { | |
| 418 | _state.users.push({ | |
| 419 | id: "u5", | |
| 420 | username: "carol", | |
| 421 | email: "carol@example.com", | |
| 422 | emailVerifiedAt: new Date(), | |
| 423 | }); | |
| 424 | setNextWhereFilter((u: FakeUser) => u.id === "u5"); | |
| 425 | await sendWelcomeEmail("u5"); | |
| 426 | expect(_emails.length).toBe(1); | |
| 427 | expect(_emails[0].to).toBe("carol@example.com"); | |
| 428 | expect(_emails[0].subject).toContain("Welcome to Gluecron"); | |
| 429 | expect(_emails[0].text).toContain("carol"); | |
| 430 | }); | |
| 431 | ||
| 432 | it("no-ops + does not throw for unknown user", async () => { | |
| 433 | setNextWhereFilter(() => false); | |
| 434 | await sendWelcomeEmail("nope"); | |
| 435 | expect(_emails.length).toBe(0); | |
| 436 | }); | |
| 437 | }); | |
| 438 | ||
| 439 | // --------------------------------------------------------------------------- | |
| 440 | // GET /verify-email — HTTP-level | |
| 441 | // --------------------------------------------------------------------------- | |
| 442 | ||
| 443 | describe("GET /verify-email", () => { | |
| 444 | it("valid token → 302 /dashboard?verified=1 + fires welcome email", async () => { | |
| 445 | _state.users.push({ | |
| 446 | id: "u6", | |
| 447 | username: "dave", | |
| 448 | email: "dave@example.com", | |
| 449 | emailVerifiedAt: null, | |
| 450 | }); | |
| 451 | const { plaintext, hash } = generateVerificationToken(); | |
| 452 | _state.tokens.push({ | |
| 453 | id: "t-6", | |
| 454 | userId: "u6", | |
| 455 | email: "dave@example.com", | |
| 456 | tokenHash: hash, | |
| 457 | expiresAt: new Date(Date.now() + 1000 * 60 * 60), | |
| 458 | usedAt: null, | |
| 459 | createdAt: new Date(), | |
| 460 | }); | |
| 461 | setNextWhereFilter( | |
| 462 | (t: FakeToken) => | |
| 463 | t.tokenHash === hash && | |
| 464 | t.usedAt === null && | |
| 465 | t.expiresAt.getTime() > Date.now() | |
| 466 | ); | |
| 467 | const app = buildApp(); | |
| 468 | const res = await app.request( | |
| 469 | `/verify-email?token=${encodeURIComponent(plaintext)}` | |
| 470 | ); | |
| 471 | expect(res.status).toBe(302); | |
| 472 | expect(res.headers.get("location")).toBe("/dashboard?verified=1"); | |
| 473 | // Welcome email is fire-and-forget — give the microtask queue a tick. | |
| 474 | await new Promise((r) => setTimeout(r, 10)); | |
| 475 | expect(_emails.some((e) => e.subject.includes("Welcome"))).toBe(true); | |
| 476 | }); | |
| 477 | ||
| 478 | it("invalid token → 200 with 'Link expired' page", async () => { | |
| 479 | const app = buildApp(); | |
| 480 | const res = await app.request("/verify-email?token=not-a-real-token"); | |
| 481 | expect(res.status).toBe(200); | |
| 482 | const body = await res.text(); | |
| 483 | expect(body).toContain("Link expired"); | |
| 484 | }); | |
| 485 | }); | |
| 486 | ||
| 487 | // --------------------------------------------------------------------------- | |
| 488 | // Rate limit smoke | |
| 489 | // --------------------------------------------------------------------------- | |
| 490 | ||
| 491 | describe("POST /verify-email/resend rate limit", () => { | |
| 492 | it("__resetResendRateLimitForTests is exported + idempotent", async () => { | |
| 493 | const mod = await import("../routes/email-verification"); | |
| 494 | expect(typeof mod.__resetResendRateLimitForTests).toBe("function"); | |
| 495 | mod.__resetResendRateLimitForTests(); | |
| 496 | mod.__resetResendRateLimitForTests(); | |
| 497 | }); | |
| 498 | }); | |
| 499 | ||
| 500 | // --------------------------------------------------------------------------- | |
| 501 | // Register POST wires through to startEmailVerification | |
| 502 | // --------------------------------------------------------------------------- | |
| 503 | ||
| 504 | describe("POST /register → startEmailVerification wiring", () => { | |
| 505 | it("startEmailVerification produces a token row (the wire-up's payload)", async () => { | |
| 506 | _state.users.push({ | |
| 507 | id: "u-reg", | |
| 508 | username: "newbie", | |
| 509 | email: "newbie@example.com", | |
| 510 | emailVerifiedAt: null, | |
| 511 | }); | |
| 512 | setNextWhereFilter((u: FakeUser) => u.id === "u-reg"); | |
| 513 | const r = await startEmailVerification("u-reg", "newbie@example.com"); | |
| 514 | expect(r.ok).toBe(true); | |
| 515 | expect(_state.tokens.length).toBe(1); | |
| 516 | expect(_state.tokens[0].userId).toBe("u-reg"); | |
| 517 | expect(typeof startEmailVerification).toBe("function"); | |
| 518 | }); | |
| 519 | ||
| 520 | it("auth.tsx /register handler imports email-verification", async () => { | |
| 521 | const m = await import("../lib/email-verification"); | |
| 522 | expect(typeof m.startEmailVerification).toBe("function"); | |
| 523 | expect(typeof m.consumeVerificationToken).toBe("function"); | |
| 524 | expect(typeof m.sendWelcomeEmail).toBe("function"); | |
| 525 | expect(authRoutes).toBeDefined(); | |
| 526 | }); | |
| 527 | }); |