add coverage reporting + improve test coverage for undertested files
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 2m10s

This commit is contained in:
OpenClaw Subagent 2026-03-12 14:09:54 +01:00
parent 55172856b1
commit 0a17e27fcd
6 changed files with 1311 additions and 0 deletions

View file

@ -0,0 +1,455 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import request from "supertest";
// Mock logger
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.mock("../services/logger.js", () => ({
default: mockLogger,
}));
// Mock keys service
const mockCreateProKey = vi.fn();
const mockDowngradeByCustomer = vi.fn();
const mockUpdateEmailByCustomer = vi.fn();
const mockFindKeyByCustomerId = vi.fn();
vi.mock("../services/keys.js", () => ({
createProKey: mockCreateProKey,
downgradeByCustomer: mockDowngradeByCustomer,
updateEmailByCustomer: mockUpdateEmailByCustomer,
findKeyByCustomerId: mockFindKeyByCustomerId,
}));
// Mock billing templates
const mockRenderSuccessPage = vi.fn();
const mockRenderAlreadyProvisionedPage = vi.fn();
vi.mock("../utils/billing-templates.js", () => ({
renderSuccessPage: mockRenderSuccessPage,
renderAlreadyProvisionedPage: mockRenderAlreadyProvisionedPage,
}));
// Mock Stripe
const mockStripe = {
checkout: {
sessions: {
create: vi.fn(),
retrieve: vi.fn(),
listLineItems: vi.fn(),
},
},
customers: {
retrieve: vi.fn(),
update: vi.fn(),
},
subscriptions: {
retrieve: vi.fn(),
},
webhooks: {
constructEvent: vi.fn(),
},
products: {
list: vi.fn(),
},
prices: {
list: vi.fn(),
create: vi.fn(),
},
};
vi.mock("stripe", () => ({
default: vi.fn().mockImplementation(() => mockStripe),
}));
describe("Billing Error Paths", () => {
let app: any;
let billingRouter: any;
beforeEach(async () => {
vi.clearAllMocks();
// Set up environment
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
// Mock default responses
mockRenderSuccessPage.mockReturnValue("<html>Success</html>");
mockRenderAlreadyProvisionedPage.mockReturnValue("<html>Already Provisioned</html>");
// Import and set up minimal Express app
const express = await import("express");
const billingModule = await import("../routes/billing.js");
app = express.default();
app.use(express.default.json());
app.use(express.default.raw({ type: "application/json" }));
app.use("/", billingModule.default);
billingRouter = billingModule.default;
});
afterEach(() => {
delete process.env.STRIPE_SECRET_KEY;
delete process.env.STRIPE_WEBHOOK_SECRET;
});
describe("getStripe function error handling", () => {
it("should throw error when STRIPE_SECRET_KEY is not configured", async () => {
delete process.env.STRIPE_SECRET_KEY;
// Clear the module cache to get a fresh import
vi.resetModules();
// Re-import the billing module
const express = await import("express");
const testApp = express.default();
testApp.use(express.default.json());
// Import billing routes (this should work despite missing env var)
const billingModule = await import("../routes/billing.js");
testApp.use("/", billingModule.default);
// Making a checkout request should trigger the getStripe() function and fail
const response = await request(testApp)
.post("/checkout")
.send({ email: "test@example.com" });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "STRIPE_SECRET_KEY not configured",
}),
}),
"Checkout error"
);
});
});
describe("checkout error handling", () => {
beforeEach(() => {
process.env.STRIPE_SECRET_KEY = "sk_test_123";
});
it("should handle Stripe session creation failure", async () => {
const stripeError = new Error("Stripe down");
stripeError.code = "api_connection_error";
mockStripe.checkout.sessions.create.mockRejectedValueOnce(stripeError);
const response = await request(app)
.post("/checkout")
.send({ email: "test@example.com" });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Stripe down",
}),
}),
"Checkout error"
);
});
it("should handle product/price retrieval failure during checkout", async () => {
const stripeError = new Error("Product not found");
stripeError.code = "resource_missing";
mockStripe.products.list.mockRejectedValueOnce(stripeError);
const response = await request(app)
.post("/checkout")
.send({ email: "test@example.com" });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Product not found",
}),
}),
"Checkout error"
);
});
});
describe("success page error handling", () => {
it("should handle missing session_id parameter", async () => {
const response = await request(app)
.get("/success");
expect(response.status).toBe(400);
expect(response.text).toBe("Missing session_id");
});
it("should handle Stripe session retrieval failure", async () => {
const stripeError = new Error("Session expired");
stripeError.code = "resource_missing";
mockStripe.checkout.sessions.retrieve.mockRejectedValueOnce(stripeError);
const response = await request(app)
.get("/success?session_id=cs_test_invalid");
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Session expired",
}),
}),
"Success page error"
);
});
it("should handle line items retrieval failure", async () => {
mockStripe.checkout.sessions.retrieve.mockResolvedValueOnce({
id: "cs_test_123",
status: "complete",
customer: "cus_test",
customer_email: "test@example.com",
});
const stripeError = new Error("Stripe retrieve failed");
stripeError.code = "api_error";
mockStripe.checkout.sessions.listLineItems.mockRejectedValueOnce(stripeError);
const response = await request(app)
.get("/success?session_id=cs_test_123");
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Stripe retrieve failed",
}),
sessionId: "cs_test_123",
}),
"Failed to retrieve session line_items"
);
});
});
describe("webhook error handling", () => {
it("should reject webhooks without signature header", async () => {
const response = await request(app)
.post("/webhook")
.send({ type: "checkout.session.completed" });
expect(response.status).toBe(400);
expect(response.text).toBe("Missing stripe-signature header");
});
it("should reject webhooks when STRIPE_WEBHOOK_SECRET is not configured", async () => {
delete process.env.STRIPE_WEBHOOK_SECRET;
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "t=123,v1=abc")
.send({ type: "checkout.session.completed" });
expect(response.status).toBe(400);
expect(response.text).toBe("Webhook secret not configured");
expect(mockLogger.error).toHaveBeenCalledWith(
"STRIPE_WEBHOOK_SECRET is not configured — refusing to process unverified webhooks"
);
});
it("should handle invalid webhook signature", async () => {
const signatureError = new Error("Invalid signature");
signatureError.code = "signature_verification_failed";
mockStripe.webhooks.constructEvent.mockImplementation(() => {
throw signatureError;
});
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "invalid_signature")
.send(JSON.stringify({ type: "checkout.session.completed" }));
expect(response.status).toBe(400);
expect(response.text).toBe("Invalid signature");
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Invalid signature",
}),
}),
"Webhook signature verification failed"
);
});
it("should handle checkout.session.completed with missing data gracefully", async () => {
const webhookEvent = {
type: "checkout.session.completed",
data: {
object: {
id: "cs_test_123",
customer: null, // Missing customer
customer_email: null, // Missing email
},
},
};
mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent);
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "valid_signature")
.send(JSON.stringify(webhookEvent));
expect(response.status).toBe(200);
expect(mockLogger.warn).toHaveBeenCalledWith(
"checkout.session.completed: missing customerId or email, skipping key provisioning"
);
});
it("should handle subscription retrieval failure during webhook processing", async () => {
const webhookEvent = {
type: "customer.subscription.updated",
data: {
object: {
id: "sub_test_123",
customer: "cus_test_123",
status: "active",
cancel_at_period_end: false,
items: {
data: [{ price: { product: "prod_test" } }],
},
},
},
};
mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent);
const stripeError = new Error("Subscription not found");
stripeError.code = "resource_missing";
mockStripe.subscriptions.retrieve.mockRejectedValueOnce(stripeError);
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "valid_signature")
.send(JSON.stringify(webhookEvent));
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Subscription not found",
}),
}),
"Error processing webhook"
);
});
});
describe("session cleanup functionality", () => {
it("should clean up old provisioned sessions", () => {
vi.useFakeTimers();
// Set a fixed time
const fixedTime = new Date("2022-01-02T12:00:00Z").getTime();
vi.setSystemTime(fixedTime);
// Manually trigger the cleanup logic by advancing time
// The cleanup interval is 1 hour, so advance by more than that
vi.advanceTimersByTime(60 * 60 * 1000 + 1000); // 1 hour + 1 second
// The cleanup function should log when it runs
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({
cleanedCount: expect.any(Number),
remainingCount: expect.any(Number),
}),
"Cleaned up expired provisioned sessions"
);
vi.useRealTimers();
});
});
describe("edge cases in webhook event processing", () => {
it("should handle customer email update for missing customer", async () => {
const webhookEvent = {
type: "customer.updated",
data: {
object: {
id: "cus_nonexistent",
email: "newemail@test.com",
},
},
};
mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent);
mockFindKeyByCustomerId.mockResolvedValueOnce(null); // Customer not found
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "valid_signature")
.send(JSON.stringify(webhookEvent));
expect(response.status).toBe(200);
// Should not attempt to update email if customer key is not found
expect(mockUpdateEmailByCustomer).not.toHaveBeenCalled();
});
it("should handle database errors during key provisioning", async () => {
const webhookEvent = {
type: "checkout.session.completed",
data: {
object: {
id: "cs_test_123",
customer: "cus_test_123",
customer_email: "test@example.com",
},
},
};
mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent);
const dbError = new Error("Database connection failed");
mockCreateProKey.mockRejectedValueOnce(dbError);
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "valid_signature")
.send(JSON.stringify(webhookEvent));
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({
message: "Database connection failed",
}),
}),
"Error processing webhook"
);
});
it("should handle unknown webhook event types gracefully", async () => {
const webhookEvent = {
type: "unknown.event.type",
data: {
object: {
id: "obj_123",
},
},
};
mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent);
const response = await request(app)
.post("/webhook")
.set("stripe-signature", "valid_signature")
.send(JSON.stringify(webhookEvent));
expect(response.status).toBe(200);
// Should not crash or log errors for unknown event types
expect(mockLogger.error).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,316 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Don't use the global mock — we test the real browser service
vi.unmock("../services/browser.js");
// Mock logger
vi.mock("../services/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function createMockPage() {
const page: any = {
setJavaScriptEnabled: vi.fn().mockResolvedValue(undefined),
setContent: vi.fn().mockResolvedValue(undefined),
addStyleTag: vi.fn().mockResolvedValue(undefined),
pdf: vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test")),
goto: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
setRequestInterception: vi.fn().mockResolvedValue(undefined),
removeAllListeners: vi.fn().mockReturnThis(),
createCDPSession: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
detach: vi.fn().mockResolvedValue(undefined),
}),
cookies: vi.fn().mockResolvedValue([{ name: "test", value: "cookie" }]),
deleteCookie: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
continue: vi.fn().mockResolvedValue(undefined),
abort: vi.fn().mockResolvedValue(undefined),
};
return page;
}
function createMockBrowser(pagesPerBrowser = 2) {
const pages = Array.from({ length: pagesPerBrowser }, () => createMockPage());
let pageIndex = 0;
const browser: any = {
newPage: vi.fn().mockImplementation(() => Promise.resolve(pages[pageIndex++] || createMockPage())),
close: vi.fn().mockResolvedValue(undefined),
_pages: pages,
};
return browser;
}
// Set env vars before importing
process.env.BROWSER_COUNT = "1";
process.env.PAGES_PER_BROWSER = "1";
let mockBrowsers: any[] = [];
let launchCallCount = 0;
// Mock puppeteer
vi.mock("puppeteer", () => {
return {
default: {
launch: vi.fn().mockImplementation(() => {
const browser = createMockBrowser(1);
mockBrowsers.push(browser);
launchCallCount++;
return Promise.resolve(browser);
}),
},
};
});
describe("Browser Service Error Handling", () => {
let browserService: any;
beforeEach(async () => {
vi.clearAllMocks();
mockBrowsers = [];
launchCallCount = 0;
// Dynamic import to ensure mocks are set up
browserService = await import("../services/browser.js");
await browserService.initBrowser();
});
afterEach(async () => {
await browserService.closeBrowser();
});
describe("recyclePage error handling", () => {
it("should handle CDP session creation failure gracefully", async () => {
const failingPage = createMockPage();
failingPage.createCDPSession = vi.fn().mockRejectedValue(new Error("CDP failed"));
// Should not throw
await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined();
});
it("should handle CDP detach failure gracefully", async () => {
const failingPage = createMockPage();
failingPage.createCDPSession = vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
detach: vi.fn().mockRejectedValue(new Error("Detach failed")),
});
// Should not throw
await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined();
});
it("should handle setRequestInterception failure gracefully", async () => {
const failingPage = createMockPage();
failingPage.setRequestInterception = vi.fn().mockRejectedValue(new Error("Request interception failed"));
// Should not throw
await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined();
});
it("should handle goto failure gracefully", async () => {
const failingPage = createMockPage();
failingPage.goto = vi.fn().mockRejectedValue(new Error("Goto failed"));
// Should not throw
await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined();
});
it("should handle deleteCookie failure gracefully", async () => {
const failingPage = createMockPage();
failingPage.deleteCookie = vi.fn().mockRejectedValue(new Error("Delete cookie failed"));
// Should not throw
await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined();
});
});
describe("queue timeout handling", () => {
it("should timeout and reject with QUEUE_FULL when all pages are busy", async () => {
// Simulate all pages being busy by not releasing any
const result1 = browserService.renderPdf("<html><body>Test 1</body></html>");
const result2 = browserService.renderPdf("<html><body>Test 2</body></html>");
// This third request should queue and eventually timeout
const timeoutPromise = browserService.renderPdf("<html><body>Test 3</body></html>");
// Fast-forward time to trigger timeout
vi.useFakeTimers();
const startTime = Date.now();
await expect(async () => {
vi.advanceTimersByTime(30_000); // Advance by 30 seconds to trigger timeout
await timeoutPromise;
}).rejects.toThrow("QUEUE_FULL");
vi.useRealTimers();
// Clean up the running renders
await result1;
await result2;
}, 35000);
});
describe("renderUrlPdf with hostResolverRules error handling", () => {
it("should handle request interception errors gracefully", async () => {
const mockPage = createMockPage();
mockPage.goto = vi.fn().mockResolvedValue(undefined);
mockPage.pdf = vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test"));
mockPage.setRequestInterception = vi.fn().mockResolvedValue(undefined);
// Mock the on method to simulate request handling
let requestHandler: any;
mockPage.on = vi.fn().mockImplementation((event: string, handler: any) => {
if (event === "request") {
requestHandler = handler;
}
});
const mockRequest = {
url: () => "http://example.com/test",
headers: () => ({}),
continue: vi.fn().mockRejectedValue(new Error("Continue failed")),
abort: vi.fn().mockResolvedValue(undefined),
};
// Replace the mock browser's newPage to return our special mock page
const mockBrowser = mockBrowsers[0];
mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage);
const result = await browserService.renderUrlPdf("http://example.com/test", {
hostResolverRules: "MAP example.com 192.168.1.1",
});
expect(result.pdf).toBeInstanceOf(Buffer);
expect(mockPage.setRequestInterception).toHaveBeenCalledWith(true);
});
it("should block requests to non-target hosts", async () => {
const mockPage = createMockPage();
mockPage.goto = vi.fn().mockResolvedValue(undefined);
mockPage.pdf = vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test"));
let requestHandler: any;
mockPage.on = vi.fn().mockImplementation((event: string, handler: any) => {
if (event === "request") {
requestHandler = handler;
}
});
const mockRequest = {
url: () => "http://malicious.com/evil",
abort: vi.fn().mockResolvedValue(undefined),
};
// Replace the mock browser's newPage
const mockBrowser = mockBrowsers[0];
mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage);
await browserService.renderUrlPdf("http://example.com/test", {
hostResolverRules: "MAP example.com 192.168.1.1",
});
// Simulate a request to a different host
if (requestHandler) {
requestHandler(mockRequest);
expect(mockRequest.abort).toHaveBeenCalledWith("blockedbyclient");
}
});
});
describe("PDF generation timeout", () => {
it("should timeout during PDF generation when page hangs", async () => {
vi.useFakeTimers();
const mockPage = createMockPage();
// Make setContent hang forever
mockPage.setContent = vi.fn().mockImplementation(() => new Promise(() => {}));
const mockBrowser = mockBrowsers[0];
mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage);
const renderPromise = browserService.renderPdf("<html><body>Hanging test</body></html>");
// Fast-forward past the timeout
vi.advanceTimersByTime(30_000);
await expect(renderPromise).rejects.toThrow("PDF_TIMEOUT");
vi.useRealTimers();
});
it("should timeout during URL navigation when page hangs", async () => {
vi.useFakeTimers();
const mockPage = createMockPage();
// Make goto hang forever
mockPage.goto = vi.fn().mockImplementation(() => new Promise(() => {}));
const mockBrowser = mockBrowsers[0];
mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage);
const renderPromise = browserService.renderUrlPdf("http://example.com/hanging");
// Fast-forward past the timeout
vi.advanceTimersByTime(30_000);
await expect(renderPromise).rejects.toThrow("PDF_TIMEOUT");
vi.useRealTimers();
});
});
describe("buildPdfOptions edge cases", () => {
it("should handle all optional parameters being set", () => {
const options = {
format: "Legal" as const,
landscape: true,
margin: { top: "1in", right: "0.5in", bottom: "1in", left: "0.5in" },
printBackground: false,
headerTemplate: "<div>Header</div>",
footerTemplate: "<div>Footer</div>",
displayHeaderFooter: true,
scale: 0.8,
pageRanges: "1-3",
preferCSSPageSize: true,
width: "8.5in",
height: "11in",
};
const result = browserService.buildPdfOptions(options);
expect(result).toEqual({
format: "Legal",
landscape: true,
margin: { top: "1in", right: "0.5in", bottom: "1in", left: "0.5in" },
printBackground: false,
headerTemplate: "<div>Header</div>",
footerTemplate: "<div>Footer</div>",
displayHeaderFooter: true,
scale: 0.8,
pageRanges: "1-3",
preferCSSPageSize: true,
width: "8.5in",
height: "11in",
});
});
it("should handle empty string values correctly", () => {
const options = {
headerTemplate: "",
footerTemplate: "",
pageRanges: "",
width: "",
height: "",
};
const result = browserService.buildPdfOptions(options);
expect(result.headerTemplate).toBe("");
expect(result.footerTemplate).toBe("");
expect(result.pageRanges).toBe("");
expect(result.width).toBe("");
expect(result.height).toBe("");
});
});
});

View file

@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock logger
vi.mock("../services/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// Mock pg
const mockQuery = vi.fn();
const mockConnect = vi.fn();
const mockRelease = vi.fn();
const mockClient = {
query: mockQuery,
release: mockRelease,
};
const mockPool = {
connect: mockConnect,
on: vi.fn(),
};
vi.mock("pg", () => ({
Pool: vi.fn().mockImplementation(() => mockPool),
}));
// Mock error utilities
vi.mock("../utils/errors.js", () => ({
isTransientError: vi.fn(),
errorMessage: vi.fn(),
errorCode: vi.fn(),
}));
describe("Database Retry Logic", () => {
let dbService: any;
let isTransientError: any;
let errorMessage: any;
let errorCode: any;
beforeEach(async () => {
vi.clearAllMocks();
mockQuery.mockReset();
mockConnect.mockReset();
mockRelease.mockReset();
// Get the mocked functions
const errorUtils = await import("../utils/errors.js");
isTransientError = errorUtils.isTransientError;
errorMessage = errorUtils.errorMessage;
errorCode = errorUtils.errorCode;
// Setup default mocks
isTransientError.mockImplementation((err: any) => {
return err.code === "ENOTFOUND" || err.code === "ECONNRESET" || err.code === "ETIMEDOUT";
});
errorMessage.mockImplementation((err: any) => err.message || "Unknown error");
errorCode.mockImplementation((err: any) => err.code || "UNKNOWN");
mockConnect.mockResolvedValue(mockClient);
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
// Import the service after mocks are set up
dbService = await import("../services/db.js");
});
describe("queryWithRetry", () => {
it("should succeed on first attempt for successful queries", async () => {
const expectedResult = { rows: [{ id: 1, name: "test" }], rowCount: 1 };
mockQuery.mockResolvedValueOnce(expectedResult);
const result = await dbService.queryWithRetry("SELECT * FROM test", []);
expect(result).toEqual(expectedResult);
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledWith(); // Normal release
});
it("should retry on transient errors and eventually succeed", async () => {
vi.useFakeTimers();
const transientError = new Error("Connection lost");
transientError.code = "ECONNRESET";
const expectedResult = { rows: [{ id: 1 }], rowCount: 1 };
// First two calls fail, third succeeds
mockQuery
.mockRejectedValueOnce(transientError)
.mockRejectedValueOnce(transientError)
.mockResolvedValueOnce(expectedResult);
const queryPromise = dbService.queryWithRetry("SELECT * FROM test", [], 3);
// Advance timers to handle retry delays
vi.advanceTimersByTime(1000); // First retry delay
vi.advanceTimersByTime(2000); // Second retry delay
const result = await queryPromise;
expect(result).toEqual(expectedResult);
expect(mockConnect).toHaveBeenCalledTimes(3);
expect(mockQuery).toHaveBeenCalledTimes(3);
// Should have called release(true) for failed attempts to destroy bad connections
expect(mockRelease).toHaveBeenCalledWith(true);
expect(mockRelease).toHaveBeenCalledWith(); // Final successful release
vi.useRealTimers();
});
it("should fail immediately on non-transient errors", async () => {
const nonTransientError = new Error("Syntax error");
nonTransientError.code = "42601"; // PostgreSQL syntax error
isTransientError.mockReturnValue(false);
mockQuery.mockRejectedValueOnce(nonTransientError);
await expect(dbService.queryWithRetry("INVALID SQL", [])).rejects.toThrow("Syntax error");
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledWith(true); // Should destroy the connection even for non-transient errors
});
it("should fail after exhausting all retries", async () => {
vi.useFakeTimers();
const transientError = new Error("Network timeout");
transientError.code = "ETIMEDOUT";
mockQuery.mockRejectedValue(transientError);
const queryPromise = dbService.queryWithRetry("SELECT * FROM test", [], 2);
// Advance timers for all retry attempts
vi.advanceTimersByTime(1000); // First retry
vi.advanceTimersByTime(2000); // Second retry
await expect(queryPromise).rejects.toThrow("Network timeout");
expect(mockConnect).toHaveBeenCalledTimes(3); // Initial + 2 retries
expect(mockQuery).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
it("should handle client connect failures", async () => {
const connectError = new Error("Connection refused");
connectError.code = "ECONNREFUSED";
mockConnect.mockRejectedValueOnce(connectError);
await expect(dbService.queryWithRetry("SELECT 1", [])).rejects.toThrow("Connection refused");
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockQuery).not.toHaveBeenCalled();
});
it("should handle client release failures gracefully", async () => {
const expectedResult = { rows: [], rowCount: 0 };
mockQuery.mockResolvedValueOnce(expectedResult);
mockRelease.mockImplementation(() => {
throw new Error("Release failed");
});
// Should not throw despite release failure
const result = await dbService.queryWithRetry("SELECT 1", []);
expect(result).toEqual(expectedResult);
});
});
describe("connectWithRetry", () => {
it("should succeed on first attempt for healthy connections", async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ "?column?": 1 }], rowCount: 1 });
const client = await dbService.connectWithRetry();
expect(client).toBe(mockClient);
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledWith("SELECT 1"); // Validation query
});
it("should retry when connection validation fails", async () => {
vi.useFakeTimers();
const validationError = new Error("Connection timeout");
validationError.code = "ETIMEDOUT";
// First connect succeeds, but validation fails
// Second connect and validation succeed
mockQuery
.mockRejectedValueOnce(validationError)
.mockResolvedValueOnce({ rows: [{ "?column?": 1 }], rowCount: 1 });
const connectPromise = dbService.connectWithRetry(2);
// Advance timer for retry delay
vi.advanceTimersByTime(1000);
const client = await connectPromise;
expect(client).toBe(mockClient);
expect(mockConnect).toHaveBeenCalledTimes(2);
expect(mockQuery).toHaveBeenCalledTimes(2);
expect(mockRelease).toHaveBeenCalledWith(true); // First connection destroyed due to validation failure
vi.useRealTimers();
});
it("should fail immediately on non-transient connect errors", async () => {
const nonTransientError = new Error("Authentication failed");
nonTransientError.code = "28P01"; // PostgreSQL auth error
isTransientError.mockReturnValue(false);
mockConnect.mockRejectedValueOnce(nonTransientError);
await expect(dbService.connectWithRetry()).rejects.toThrow("Authentication failed");
expect(mockConnect).toHaveBeenCalledTimes(1);
});
it("should fail after exhausting connect retries", async () => {
vi.useFakeTimers();
const transientError = new Error("Connection timeout");
transientError.code = "ETIMEDOUT";
mockConnect.mockRejectedValue(transientError);
const connectPromise = dbService.connectWithRetry(2);
// Advance timers for retry delays
vi.advanceTimersByTime(1000); // First retry
vi.advanceTimersByTime(2000); // Second retry
await expect(connectPromise).rejects.toThrow("Connection timeout");
expect(mockConnect).toHaveBeenCalledTimes(3); // Initial + 2 retries
vi.useRealTimers();
});
it("should fail on non-transient validation errors", async () => {
const validationError = new Error("Permission denied");
validationError.code = "42501"; // PostgreSQL permission error
isTransientError.mockReturnValue(false);
mockQuery.mockRejectedValueOnce(validationError);
await expect(dbService.connectWithRetry()).rejects.toThrow("Permission denied");
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledWith(true); // Connection destroyed due to validation failure
});
it("should handle release failure during validation error cleanup", async () => {
const validationError = new Error("Validation failed");
validationError.code = "ETIMEDOUT";
mockQuery.mockRejectedValueOnce(validationError);
mockRelease.mockImplementation(() => {
throw new Error("Release failed");
});
// Should still throw the validation error, not the release error
await expect(dbService.connectWithRetry(1)).rejects.toThrow("Validation failed");
});
});
describe("cleanupStaleData", () => {
it("should clean up expired verifications and orphaned usage", async () => {
// Mock the cleanup queries
mockQuery
.mockResolvedValueOnce({ rowCount: 3 }) // expired verifications
.mockResolvedValueOnce({ rowCount: 7 }); // orphaned usage
const result = await dbService.cleanupStaleData();
expect(result).toEqual({
expiredVerifications: 3,
orphanedUsage: 7,
});
expect(mockQuery).toHaveBeenCalledWith(
"DELETE FROM pending_verifications WHERE expires_at < NOW() RETURNING email"
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("DELETE FROM usage")
);
});
it("should handle null rowCount gracefully", async () => {
// Mock queries returning null rowCount
mockQuery
.mockResolvedValueOnce({ rowCount: null })
.mockResolvedValueOnce({ rowCount: null });
const result = await dbService.cleanupStaleData();
expect(result).toEqual({
expiredVerifications: 0,
orphanedUsage: 0,
});
});
it("should handle database errors during cleanup", async () => {
const dbError = new Error("Table does not exist");
mockQuery.mockRejectedValueOnce(dbError);
await expect(dbService.cleanupStaleData()).rejects.toThrow("Table does not exist");
});
});
describe("initDatabase", () => {
it("should create all required tables", async () => {
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await dbService.initDatabase();
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("CREATE TABLE IF NOT EXISTS api_keys")
);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("should handle table creation failures", async () => {
const createError = new Error("Permission denied to create table");
mockQuery.mockRejectedValueOnce(createError);
await expect(dbService.initDatabase()).rejects.toThrow("Permission denied to create table");
expect(mockRelease).toHaveBeenCalledTimes(1); // Should still release the client
});
});
});