diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts index b58d15c..346b75f 100644 --- a/src/__tests__/email.test.ts +++ b/src/__tests__/email.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -// Mock nodemailer before importing email service -const mockSendMail = vi.fn(); +vi.unmock("../services/email.js"); + +const { mockSendMail } = vi.hoisted(() => ({ + mockSendMail: vi.fn(), +})); + vi.mock("nodemailer", () => ({ default: { createTransport: vi.fn(() => ({ @@ -10,7 +14,6 @@ vi.mock("nodemailer", () => ({ }, })); -// Mock logger vi.mock("../services/logger.js", () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); @@ -25,35 +28,28 @@ describe("Email Service", () => { describe("sendVerificationEmail", () => { it("constructs correct email with code", async () => { mockSendMail.mockResolvedValueOnce({ messageId: "test-123" }); - const result = await sendVerificationEmail("user@example.com", "654321"); expect(result).toBe(true); expect(mockSendMail).toHaveBeenCalledOnce(); - - const mailOptions = mockSendMail.mock.calls[0][0]; - expect(mailOptions.to).toBe("user@example.com"); - expect(mailOptions.subject).toContain("DocFast"); - expect(mailOptions.subject).toContain("Verify"); - expect(mailOptions.text).toContain("654321"); - expect(mailOptions.html).toContain("654321"); + const opts = mockSendMail.mock.calls[0][0]; + expect(opts.to).toBe("user@example.com"); + expect(opts.subject).toContain("Verify"); + expect(opts.text).toContain("654321"); + expect(opts.html).toContain("654321"); }); it("returns false when SMTP fails", async () => { mockSendMail.mockRejectedValueOnce(new Error("SMTP connection refused")); - const result = await sendVerificationEmail("user@example.com", "123456"); - expect(result).toBe(false); }); it("includes expiry notice in email body", async () => { mockSendMail.mockResolvedValueOnce({ messageId: "test-456" }); - await sendVerificationEmail("user@example.com", "111111"); - - const mailOptions = mockSendMail.mock.calls[0][0]; - expect(mailOptions.text).toContain("15 minutes"); + const opts = mockSendMail.mock.calls[0][0]; + expect(opts.text).toContain("15 minutes"); }); }); }); diff --git a/src/__tests__/verification.test.ts b/src/__tests__/verification.test.ts new file mode 100644 index 0000000..50187fd --- /dev/null +++ b/src/__tests__/verification.test.ts @@ -0,0 +1,120 @@ +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); + }); + }); +});