diff --git a/package-lock.json b/package-lock.json index 947819a..1694a5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "nodemailer": "^8.0.2", "pg": "^8.20.0", "pino": "^10.3.1", - "puppeteer": "^24.39.0", + "puppeteer": "^24.39.1", "stripe": "^20.4.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.32.0" @@ -1637,9 +1637,9 @@ } }, "node_modules/bare-os": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz", - "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -4149,9 +4149,9 @@ } }, "node_modules/puppeteer": { - "version": "24.39.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.0.tgz", - "integrity": "sha512-uMpGyuPqz94YInmdHSbD9ssgwsddrwe8qXr08UaEwjzrEvOa8gGl8za0h+MWoEG+/6sIBsJwzRfwuGCYRbbcpg==", + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.1.tgz", + "integrity": "sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4159,7 +4159,7 @@ "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1581282", - "puppeteer-core": "24.39.0", + "puppeteer-core": "24.39.1", "typed-query-selector": "^2.12.1" }, "bin": { @@ -4170,9 +4170,9 @@ } }, "node_modules/puppeteer-core": { - "version": "24.39.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.0.tgz", - "integrity": "sha512-SzIxz76Kgu17HUIi57HOejPiN0JKa9VCd2GcPY1sAh6RA4BzGZarFQdOYIYrBdUVbtyH7CrDb9uhGEwVXK/YNA==", + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz", + "integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==", "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.13.0", diff --git a/package.json b/package.json index 0de53f4..99e4cca 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "nodemailer": "^8.0.2", "pg": "^8.20.0", "pino": "^10.3.1", - "puppeteer": "^24.39.0", + "puppeteer": "^24.39.1", "stripe": "^20.4.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.32.0" diff --git a/src/__tests__/recover-coverage.test.ts b/src/__tests__/recover-coverage.test.ts new file mode 100644 index 0000000..ba887fb --- /dev/null +++ b/src/__tests__/recover-coverage.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../services/db.js", () => ({ + default: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + pool: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + queryWithRetry: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connectWithRetry: vi.fn().mockResolvedValue(undefined), + initDatabase: vi.fn().mockResolvedValue(undefined), + cleanupStaleData: vi.fn(), + isTransientError: vi.fn(), +})); + +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("../services/verification.js", () => ({ + createPendingVerification: vi.fn(), + verifyCode: vi.fn(), +})); + +vi.mock("../services/email.js", () => ({ + sendVerificationEmail: vi.fn(), +})); + +vi.mock("../services/keys.js", () => ({ + getAllKeys: vi.fn().mockReturnValue([]), + loadKeys: vi.fn().mockResolvedValue(undefined), + isValidKey: vi.fn(), + getKeyInfo: vi.fn(), + isProKey: vi.fn(), + createFreeKey: vi.fn(), + createProKey: vi.fn(), + downgradeByCustomer: vi.fn(), + findKeyByCustomerId: vi.fn(), + updateKeyEmail: vi.fn(), + updateEmailByCustomer: vi.fn(), +})); + +import { queryWithRetry } from "../services/db.js"; +import { getAllKeys } from "../services/keys.js"; +import { createPendingVerification } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import logger from "../services/logger.js"; +import express from "express"; +import request from "supertest"; +import { recoverRouter } from "../routes/recover.js"; + +const mockQuery = vi.mocked(queryWithRetry); +const mockGetAllKeys = vi.mocked(getAllKeys); +const mockCreatePending = vi.mocked(createPendingVerification); +const mockSendEmail = vi.mocked(sendVerificationEmail); +const mockLogger = vi.mocked(logger); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use("/v1/recover", recoverRouter); + return app; +} + +describe("recover.ts sendVerificationEmail error handlers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs error when sendVerificationEmail fails in DB fallback path (line 80)", async () => { + // No key in cache → triggers DB fallback + mockGetAllKeys.mockReturnValueOnce([]); + // DB finds a row → triggers email send + mockQuery.mockResolvedValueOnce({ + rows: [{ key: "df_free_abc", tier: "free", email: "user@test.com", created_at: "2026-01-01", stripe_customer_id: null }], + rowCount: 1, + } as any); + mockCreatePending.mockResolvedValueOnce({ email: "user@test.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 }); + + const emailError = new Error("SMTP connection failed"); + mockSendEmail.mockRejectedValueOnce(emailError); + + const app = createApp(); + const res = await request(app) + .post("/v1/recover") + .send({ email: "user@test.com" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovery_sent"); + + // Wait for the .catch to execute (it's fire-and-forget) + await new Promise(r => setTimeout(r, 50)); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ err: emailError }), + "Failed to send recovery email" + ); + }); + + it("logs error when sendVerificationEmail fails in main path (line 91)", async () => { + // Key found in cache → main path + mockGetAllKeys.mockReturnValueOnce([ + { key: "df_pro_xyz", tier: "pro" as const, email: "found@test.com", createdAt: "2025-01-01" }, + ]); + mockCreatePending.mockResolvedValueOnce({ email: "found@test.com", code: "654321", createdAt: "", expiresAt: "", attempts: 0 }); + + const emailError = new Error("Email service down"); + mockSendEmail.mockRejectedValueOnce(emailError); + + const app = createApp(); + const res = await request(app) + .post("/v1/recover") + .send({ email: "found@test.com" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovery_sent"); + + // Wait for the .catch to execute + await new Promise(r => setTimeout(r, 50)); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ err: emailError }), + "Failed to send recovery email" + ); + }); +});