import { describe, it, expect, vi, beforeEach } from "vitest"; import express from "express"; import request from "supertest"; vi.mock("../services/verification.js"); vi.mock("../services/email.js"); vi.mock("../services/db.js"); vi.mock("../services/logger.js", () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); let app: express.Express; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); const { createPendingVerification, verifyCode } = await import("../services/verification.js"); const { sendVerificationEmail } = await import("../services/email.js"); const { queryWithRetry } = await import("../services/db.js"); vi.mocked(createPendingVerification).mockResolvedValue({ email: "new@example.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 }); vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); vi.mocked(sendVerificationEmail).mockResolvedValue(true); // Default: apiKey exists, email not taken vi.mocked(queryWithRetry).mockImplementation((async (sql: string, params?: any[]) => { if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; } if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("email =")) { return { rows: [], rowCount: 0 }; } if (sql.includes("UPDATE")) { return { rows: [{ email: "new@example.com" }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; }) as any); const { emailChangeRouter } = await import("../routes/email-change.js"); app = express(); app.use(express.json()); app.use("/v1/email-change", emailChangeRouter); }); describe("POST /v1/email-change", () => { it("returns 400 for missing apiKey", async () => { const res = await request(app).post("/v1/email-change").send({ newEmail: "new@example.com" }); expect(res.status).toBe(400); }); it("returns 400 for missing newEmail", async () => { const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx" }); expect(res.status).toBe(400); }); it("returns 400 for invalid email format", async () => { const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "bad" }); expect(res.status).toBe(400); }); it("returns 403 for invalid API key", async () => { const { queryWithRetry } = await import("../services/db.js"); vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [], rowCount: 0 }; } return { rows: [], rowCount: 0 }; }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" }); expect(res.status).toBe(403); }); it("returns 409 when email already taken", async () => { const { queryWithRetry } = await import("../services/db.js"); vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 }; } if (sql.includes("SELECT") && sql.includes("email =")) { return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(409); }); it("returns 200 with verification_sent on success", async () => { const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(200); expect(res.body.status).toBe("verification_sent"); }); it("does not crash when sendVerificationEmail fails (fire-and-forget)", async () => { const { sendVerificationEmail } = await import("../services/email.js"); const logger = (await import("../services/logger.js")).default; vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("SMTP connection failed")); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(200); expect(res.body.status).toBe("verification_sent"); // Give the catch handler a moment to execute await new Promise(resolve => setTimeout(resolve, 10)); // Verify error was logged expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ email: "new@example.com" }), "Failed to send email change verification" ); }); }); describe("POST /v1/email-change/verify", () => { it("returns 400 for missing fields", async () => { const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx" }); expect(res.status).toBe(400); }); it("returns 403 for invalid API key", async () => { const { queryWithRetry } = await import("../services/db.js"); vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [], rowCount: 0 }; } return { rows: [], rowCount: 0 }; }) as any); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "fake", newEmail: "new@example.com", code: "123456" }); expect(res.status).toBe(403); expect(res.body.error).toContain("Invalid API key"); }); it("returns 400 for invalid code", async () => { const { verifyCode } = await import("../services/verification.js"); vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" }); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); expect(res.status).toBe(400); }); it("returns 410 for expired code", async () => { const { verifyCode } = await import("../services/verification.js"); vi.mocked(verifyCode).mockResolvedValue({ status: "expired" }); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); expect(res.status).toBe(410); }); it("returns 429 for max attempts", async () => { const { verifyCode } = await import("../services/verification.js"); vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" }); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); expect(res.status).toBe(429); }); it("returns 200 and updates email on success", async () => { const { queryWithRetry } = await import("../services/db.js"); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); expect(res.status).toBe(200); expect(res.body.status).toBe("ok"); expect(res.body.newEmail).toBe("new@example.com"); // Verify UPDATE was called expect(queryWithRetry).toHaveBeenCalledWith( expect.stringContaining("UPDATE"), expect.arrayContaining(["new@example.com", "df_pro_xxx"]) ); }); }); describe("POST /v1/email-change - Database failure handling", () => { it("returns 500 when validateApiKey DB query fails", async () => { const { queryWithRetry } = await import("../services/db.js"); vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection pool exhausted")); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: "Internal server error" }); }); it("returns 500 when email existence check fails", async () => { const { queryWithRetry } = await import("../services/db.js"); let callCount = 0; vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { callCount++; // First call (validateApiKey) succeeds if (callCount === 1 && sql.includes("SELECT") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; } // Second call (email check) fails throw new Error("DB connection lost"); }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: "Internal server error" }); }); it("returns 500 when createPendingVerification fails", async () => { const { createPendingVerification } = await import("../services/verification.js"); vi.mocked(createPendingVerification).mockRejectedValue(new Error("DB insert failed")); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: "Internal server error" }); }); }); describe("POST /v1/email-change/verify - Database failure handling", () => { it("returns 500 when validateApiKey DB query fails", async () => { const { queryWithRetry } = await import("../services/db.js"); vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection timeout")); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: "Internal server error" }); }); it("returns 500 when UPDATE query fails", async () => { const { queryWithRetry } = await import("../services/db.js"); let callCount = 0; vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { callCount++; // First call (validateApiKey) succeeds if (callCount === 1 && sql.includes("SELECT") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; } // Second call (UPDATE) fails throw new Error("UPDATE failed - constraint violation"); }) as any); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: "Internal server error" }); }); });