All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m26s
120 lines
5.1 KiB
TypeScript
120 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|