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

198
package-lock.json generated
View file

@ -30,6 +30,7 @@
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/swagger-jsdoc": "^6.0.4", "@types/swagger-jsdoc": "^6.0.4",
"@vitest/coverage-v8": "^4.0.18",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"terser": "^5.46.0", "terser": "^5.46.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@ -95,6 +96,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
@ -104,6 +115,46 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@ -1262,6 +1313,37 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
"integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.18",
"ast-v8-to-istanbul": "^0.3.10",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.18",
"vitest": "4.0.18"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.0.18", "version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@ -1465,6 +1547,25 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -2715,6 +2816,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -2764,6 +2875,13 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -2944,6 +3062,45 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -3013,6 +3170,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magicast": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": { "node_modules/marked": {
"version": "17.0.4", "version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
@ -4394,6 +4579,19 @@
"node": ">=14.18.0" "node": ">=14.18.0"
} }
}, },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/swagger-jsdoc": { "node_modules/swagger-jsdoc": {
"version": "6.2.8", "version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",

View file

@ -34,6 +34,7 @@
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/swagger-jsdoc": "^6.0.4", "@types/swagger-jsdoc": "^6.0.4",
"@vitest/coverage-v8": "^4.0.18",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"terser": "^5.46.0", "terser": "^5.46.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",

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

View file

@ -9,5 +9,11 @@ export default defineConfig({
...configDefaults.exclude, ...configDefaults.exclude,
"**/dist/**", "**/dist/**",
], ],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],
include: ['src/**/*.ts'],
exclude: ['src/__tests__/**', 'src/**/*.test.ts'],
},
}, },
}); });