add verification service and email service tests (13 new tests)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m26s

This commit is contained in:
Hoid 2026-02-26 07:04:39 +00:00
parent 9dcc473e78
commit 1a37765f41
2 changed files with 133 additions and 17 deletions

View file

@ -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");
});
});
});

View file

@ -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);
});
});
});