Revert "add coverage reporting + improve test coverage for undertested files"
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
This reverts commit 0a17e27fcd.
This commit is contained in:
parent
0a17e27fcd
commit
39fb8e01e7
6 changed files with 0 additions and 1311 deletions
198
package-lock.json
generated
198
package-lock.json
generated
|
|
@ -30,7 +30,6 @@
|
|||
"@types/pg": "^8.18.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"supertest": "^7.2.2",
|
||||
"terser": "^5.46.0",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
@ -96,16 +95,6 @@
|
|||
"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": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
|
|
@ -115,46 +104,6 @@
|
|||
"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": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
|
|
@ -1313,37 +1262,6 @@
|
|||
"@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": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
|
|
@ -1547,25 +1465,6 @@
|
|||
"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": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
|
|
@ -2816,16 +2715,6 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
|
|
@ -2875,13 +2764,6 @@
|
|||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -3062,45 +2944,6 @@
|
|||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -3170,34 +3013,6 @@
|
|||
"@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": {
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
|
||||
|
|
@ -4579,19 +4394,6 @@
|
|||
"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": {
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@
|
|||
"@types/pg": "^8.18.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"supertest": "^7.2.2",
|
||||
"terser": "^5.46.0",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
|
|
@ -1,455 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,11 +9,5 @@ export default defineConfig({
|
|||
...configDefaults.exclude,
|
||||
"**/dist/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/__tests__/**', 'src/**/*.test.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue