import { describe, it, expect, vi, beforeEach } from "vitest"; // Unmock verification service (setup.ts mocks it globally) vi.unmock("../services/verification.js"); const { mockQueryWithRetry } = vi.hoisted(() => ({ mockQueryWithRetry: vi.fn(), })); vi.mock("../services/db.js", () => ({ default: { on: vi.fn(), end: vi.fn() }, pool: { on: vi.fn(), end: vi.fn() }, queryWithRetry: mockQueryWithRetry, connectWithRetry: vi.fn(), initDatabase: vi.fn(), cleanupStaleData: vi.fn(), isTransientError: vi.fn(), })); vi.mock("../services/logger.js", () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); import { verifyCode, createPendingVerification } from "../services/verification.js"; describe("Verification Service", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("verifyCode", () => { it('returns "invalid" for non-existent email', async () => { mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await verifyCode("nobody@example.com", "123456"); expect(result.status).toBe("invalid"); }); it('returns "expired" for expired codes', async () => { const pastDate = new Date(Date.now() - 20 * 60 * 1000).toISOString(); mockQueryWithRetry .mockResolvedValueOnce({ rows: [{ email: "test@example.com", code: "123456", created_at: pastDate, expires_at: pastDate, attempts: 0 }], rowCount: 1, }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await verifyCode("test@example.com", "123456"); expect(result.status).toBe("expired"); }); it('returns "max_attempts" after 3 wrong attempts', async () => { const future = new Date(Date.now() + 10 * 60 * 1000).toISOString(); mockQueryWithRetry .mockResolvedValueOnce({ rows: [{ email: "test@example.com", code: "123456", created_at: new Date().toISOString(), expires_at: future, attempts: 3 }], rowCount: 1, }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await verifyCode("test@example.com", "999999"); expect(result.status).toBe("max_attempts"); }); it('returns "ok" for correct code (timing-safe comparison)', async () => { const future = new Date(Date.now() + 10 * 60 * 1000).toISOString(); mockQueryWithRetry .mockResolvedValueOnce({ rows: [{ email: "test@example.com", code: "654321", created_at: new Date().toISOString(), expires_at: future, attempts: 0 }], rowCount: 1, }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await verifyCode("test@example.com", "654321"); expect(result.status).toBe("ok"); }); it('returns "invalid" for wrong code', async () => { const future = new Date(Date.now() + 10 * 60 * 1000).toISOString(); mockQueryWithRetry .mockResolvedValueOnce({ rows: [{ email: "test@example.com", code: "654321", created_at: new Date().toISOString(), expires_at: future, attempts: 0 }], rowCount: 1, }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await verifyCode("test@example.com", "000000"); expect(result.status).toBe("invalid"); }); it("normalizes email to lowercase and trims whitespace", async () => { mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }); await verifyCode(" Test@Example.COM ", "123456"); expect(mockQueryWithRetry).toHaveBeenCalledWith(expect.stringContaining("SELECT"), ["test@example.com"]); }); }); describe("createPendingVerification", () => { it("generates a 6-digit code", async () => { mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await createPendingVerification("test@example.com"); expect(result.code).toMatch(/^\d{6}$/); }); it("replaces existing pending verification for same email", async () => { mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await createPendingVerification("test@example.com"); expect(mockQueryWithRetry).toHaveBeenCalledWith(expect.stringContaining("DELETE FROM pending_verifications"), ["test@example.com"]); }); it("sets expiry 15 minutes in the future", async () => { mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await createPendingVerification("test@example.com"); const diff = new Date(result.expiresAt).getTime() - new Date(result.createdAt).getTime(); expect(diff).toBe(15 * 60 * 1000); }); it("initializes attempts to 0", async () => { mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await createPendingVerification("test@example.com"); expect(result.attempts).toBe(0); }); }); });