All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 21m45s
590 lines
20 KiB
TypeScript
590 lines
20 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
|
|
// Mock Stripe before importing billing router
|
|
vi.mock("stripe", () => {
|
|
const mockStripe = {
|
|
checkout: {
|
|
sessions: {
|
|
create: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
},
|
|
},
|
|
webhooks: {
|
|
constructEvent: vi.fn(),
|
|
},
|
|
products: {
|
|
search: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
prices: {
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
subscriptions: {
|
|
retrieve: vi.fn(),
|
|
},
|
|
};
|
|
return { default: vi.fn(function() { return mockStripe; }), __mockStripe: mockStripe };
|
|
});
|
|
|
|
let app: express.Express;
|
|
let mockStripe: any;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
|
|
process.env.STRIPE_SECRET_KEY = "sk_test_fake";
|
|
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake";
|
|
|
|
const stripeMod = await import("stripe");
|
|
mockStripe = (stripeMod as any).__mockStripe;
|
|
|
|
// Default: product search returns existing product+price
|
|
mockStripe.products.search.mockResolvedValue({ data: [{ id: "prod_TygeG8tQPtEAdE" }] });
|
|
mockStripe.prices.list.mockResolvedValue({ data: [{ id: "price_123" }] });
|
|
|
|
const { createProKey, findKeyByCustomerId, downgradeByCustomer, updateEmailByCustomer } = await import("../services/keys.js");
|
|
vi.mocked(createProKey).mockResolvedValue({ key: "pro-key-123", tier: "pro", email: "test@test.com", createdAt: new Date().toISOString() } as any);
|
|
vi.mocked(findKeyByCustomerId).mockResolvedValue(null);
|
|
vi.mocked(downgradeByCustomer).mockResolvedValue(undefined as any);
|
|
vi.mocked(updateEmailByCustomer).mockResolvedValue(true as any);
|
|
|
|
const { billingRouter } = await import("../routes/billing.js");
|
|
app = express();
|
|
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
|
app.use(express.json());
|
|
app.use("/v1/billing", billingRouter);
|
|
});
|
|
|
|
describe("Billing Branch Coverage", () => {
|
|
describe("isDocFastSubscription - expanded product object (lines 63-67)", () => {
|
|
it("should handle expanded product object instead of string", async () => {
|
|
// Test the branch where price.product is an expanded Stripe.Product object
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "checkout.session.completed",
|
|
data: {
|
|
object: {
|
|
id: "cs_expanded_product",
|
|
customer: "cus_expanded",
|
|
customer_details: { email: "expanded@test.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Mock: line_items has price.product as an object (not a string)
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_expanded_product",
|
|
line_items: {
|
|
data: [
|
|
{
|
|
price: {
|
|
product: { id: "prod_TygeG8tQPtEAdE" } // Expanded object, not string
|
|
}
|
|
}
|
|
],
|
|
},
|
|
});
|
|
|
|
const { createProKey } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "checkout.session.completed" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(createProKey).toHaveBeenCalledWith("expanded@test.com", "cus_expanded");
|
|
});
|
|
|
|
it("should handle expanded product object in subscription.deleted webhook", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.subscription.deleted",
|
|
data: {
|
|
object: { id: "sub_expanded", customer: "cus_expanded_del" },
|
|
},
|
|
});
|
|
|
|
// subscription.retrieve returns expanded product object
|
|
mockStripe.subscriptions.retrieve.mockResolvedValue({
|
|
items: {
|
|
data: [
|
|
{
|
|
price: {
|
|
product: { id: "prod_TygeG8tQPtEAdE" } // Expanded object
|
|
}
|
|
}
|
|
]
|
|
},
|
|
});
|
|
|
|
const { downgradeByCustomer } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.subscription.deleted" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(downgradeByCustomer).toHaveBeenCalledWith("cus_expanded_del");
|
|
});
|
|
});
|
|
|
|
describe("isDocFastSubscription - error handling (lines 70-71)", () => {
|
|
it("should return false when subscriptions.retrieve throws an error", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.subscription.deleted",
|
|
data: {
|
|
object: { id: "sub_error", customer: "cus_error" },
|
|
},
|
|
});
|
|
|
|
// Mock: subscriptions.retrieve throws an error
|
|
mockStripe.subscriptions.retrieve.mockRejectedValue(new Error("Stripe API error"));
|
|
|
|
const { downgradeByCustomer } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.subscription.deleted" }));
|
|
|
|
// Should succeed but NOT downgrade (because isDocFastSubscription returns false on error)
|
|
expect(res.status).toBe(200);
|
|
expect(downgradeByCustomer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return false when subscriptions.retrieve throws in subscription.updated", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.subscription.updated",
|
|
data: {
|
|
object: {
|
|
id: "sub_update_error",
|
|
customer: "cus_update_error",
|
|
status: "canceled",
|
|
cancel_at_period_end: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
mockStripe.subscriptions.retrieve.mockRejectedValue(new Error("Network timeout"));
|
|
|
|
const { downgradeByCustomer } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.subscription.updated" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(downgradeByCustomer).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getOrCreateProPrice - no existing product (lines 316-331)", () => {
|
|
it("should create new product and price when none exists", async () => {
|
|
// Mock: no existing product found
|
|
mockStripe.products.search.mockResolvedValue({ data: [] });
|
|
|
|
// Mock: product.create returns new product
|
|
mockStripe.products.create.mockResolvedValue({ id: "prod_new_123" });
|
|
|
|
// Mock: price.create returns new price
|
|
mockStripe.prices.create.mockResolvedValue({ id: "price_new_456" });
|
|
|
|
// Mock: checkout.sessions.create succeeds
|
|
mockStripe.checkout.sessions.create.mockResolvedValue({
|
|
id: "cs_new",
|
|
url: "https://checkout.stripe.com/pay/cs_new"
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/checkout")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockStripe.products.create).toHaveBeenCalledWith({
|
|
name: "DocFast Pro",
|
|
description: "5,000 PDFs / month via API. HTML, Markdown, and URL to PDF.",
|
|
});
|
|
expect(mockStripe.prices.create).toHaveBeenCalledWith({
|
|
product: "prod_new_123",
|
|
unit_amount: 900,
|
|
currency: "eur",
|
|
recurring: { interval: "month" },
|
|
});
|
|
expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
line_items: [{ price: "price_new_456", quantity: 1 }],
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should create new price when product exists but has no active prices", async () => {
|
|
// Mock: product exists
|
|
mockStripe.products.search.mockResolvedValue({ data: [{ id: "prod_existing" }] });
|
|
|
|
// Mock: no active prices found
|
|
mockStripe.prices.list.mockResolvedValue({ data: [] });
|
|
|
|
// Mock: price.create returns new price
|
|
mockStripe.prices.create.mockResolvedValue({ id: "price_new_789" });
|
|
|
|
// Mock: checkout.sessions.create succeeds
|
|
mockStripe.checkout.sessions.create.mockResolvedValue({
|
|
id: "cs_existing",
|
|
url: "https://checkout.stripe.com/pay/cs_existing"
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/checkout")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockStripe.products.create).not.toHaveBeenCalled(); // Product exists, don't create
|
|
expect(mockStripe.prices.create).toHaveBeenCalledWith({
|
|
product: "prod_existing",
|
|
unit_amount: 900,
|
|
currency: "eur",
|
|
recurring: { interval: "month" },
|
|
});
|
|
expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
line_items: [{ price: "price_new_789", quantity: 1 }],
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Success route - customerId branch (line 163)", () => {
|
|
it("should return 400 when session.customer is null (not a string)", async () => {
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_null_customer",
|
|
customer: null, // Explicitly null, not falsy string
|
|
customer_details: { email: "test@example.com" },
|
|
});
|
|
|
|
const res = await request(app).get("/v1/billing/success?session_id=cs_null_customer");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("No customer found");
|
|
});
|
|
|
|
it("should return 400 when customer is empty string", async () => {
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_empty_customer",
|
|
customer: "", // Empty string is falsy
|
|
customer_details: { email: "test@example.com" },
|
|
});
|
|
|
|
const res = await request(app).get("/v1/billing/success?session_id=cs_empty_customer");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("No customer found");
|
|
});
|
|
|
|
it("should return 400 when customer is undefined", async () => {
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_undef_customer",
|
|
customer: undefined,
|
|
customer_details: { email: "test@example.com" },
|
|
});
|
|
|
|
const res = await request(app).get("/v1/billing/success?session_id=cs_undef_customer");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("No customer found");
|
|
});
|
|
});
|
|
|
|
describe("Webhook checkout.session.completed - hasDocfastProduct branch (line 223)", () => {
|
|
it("should skip webhook event when line_items is undefined", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "checkout.session.completed",
|
|
data: {
|
|
object: {
|
|
id: "cs_no_items",
|
|
customer: "cus_no_items",
|
|
customer_details: { email: "test@example.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Mock: line_items is undefined
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_no_items",
|
|
line_items: undefined,
|
|
});
|
|
|
|
const { createProKey } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "checkout.session.completed" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(createProKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should skip webhook event when line_items.data is empty", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "checkout.session.completed",
|
|
data: {
|
|
object: {
|
|
id: "cs_empty_items",
|
|
customer: "cus_empty_items",
|
|
customer_details: { email: "test@example.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_empty_items",
|
|
line_items: { data: [] }, // Empty array - no items
|
|
});
|
|
|
|
const { createProKey } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "checkout.session.completed" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(createProKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should skip webhook event when price is null", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "checkout.session.completed",
|
|
data: {
|
|
object: {
|
|
id: "cs_null_price",
|
|
customer: "cus_null_price",
|
|
customer_details: { email: "test@example.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_null_price",
|
|
line_items: {
|
|
data: [{ price: null }], // Null price
|
|
},
|
|
});
|
|
|
|
const { createProKey } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "checkout.session.completed" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(createProKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should skip webhook event when product is null", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "checkout.session.completed",
|
|
data: {
|
|
object: {
|
|
id: "cs_null_product",
|
|
customer: "cus_null_product",
|
|
customer_details: { email: "test@example.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValue({
|
|
id: "cs_null_product",
|
|
line_items: {
|
|
data: [{ price: { product: null } }], // Null product
|
|
},
|
|
});
|
|
|
|
const { createProKey } = await import("../services/keys.js");
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "checkout.session.completed" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(createProKey).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Webhook customer.updated event (line 284-303)", () => {
|
|
it("should sync email when both customerId and newEmail exist", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.updated",
|
|
data: {
|
|
object: {
|
|
id: "cus_email_update",
|
|
email: "newemail@example.com",
|
|
},
|
|
},
|
|
});
|
|
|
|
const { updateEmailByCustomer } = await import("../services/keys.js");
|
|
vi.mocked(updateEmailByCustomer).mockResolvedValue(true);
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.updated" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(updateEmailByCustomer).toHaveBeenCalledWith("cus_email_update", "newemail@example.com");
|
|
});
|
|
|
|
it("should not sync email when customerId is missing", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.updated",
|
|
data: {
|
|
object: {
|
|
id: undefined, // Missing customerId
|
|
email: "newemail@example.com",
|
|
},
|
|
},
|
|
});
|
|
|
|
const { updateEmailByCustomer } = await import("../services/keys.js");
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.updated" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(updateEmailByCustomer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should not sync email when email is missing", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.updated",
|
|
data: {
|
|
object: {
|
|
id: "cus_no_email",
|
|
email: null, // Missing email
|
|
},
|
|
},
|
|
});
|
|
|
|
const { updateEmailByCustomer } = await import("../services/keys.js");
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.updated" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(updateEmailByCustomer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should not sync email when email is undefined", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.updated",
|
|
data: {
|
|
object: {
|
|
id: "cus_no_email_2",
|
|
email: undefined, // Undefined email
|
|
},
|
|
},
|
|
});
|
|
|
|
const { updateEmailByCustomer } = await import("../services/keys.js");
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.updated" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(updateEmailByCustomer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should log when email update returns false", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "customer.updated",
|
|
data: {
|
|
object: {
|
|
id: "cus_no_update",
|
|
email: "newemail@example.com",
|
|
},
|
|
},
|
|
});
|
|
|
|
const { updateEmailByCustomer } = await import("../services/keys.js");
|
|
vi.mocked(updateEmailByCustomer).mockResolvedValue(false); // Update returns false
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "customer.updated" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(updateEmailByCustomer).toHaveBeenCalledWith("cus_no_update", "newemail@example.com");
|
|
// The if (updated) branch should not be executed when false
|
|
});
|
|
});
|
|
|
|
describe("Webhook default case", () => {
|
|
it("should handle unknown webhook event type", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "unknown.event.type",
|
|
data: { object: {} },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "unknown.event.type" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it("should handle payment_intent.succeeded webhook event", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "payment_intent.succeeded",
|
|
data: { object: {} },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "payment_intent.succeeded" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it("should handle invoice.payment_succeeded webhook event", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "invoice.payment_succeeded",
|
|
data: { object: {} },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/v1/billing/webhook")
|
|
.set("content-type", "application/json")
|
|
.set("stripe-signature", "valid_sig")
|
|
.send(JSON.stringify({ type: "invoice.payment_succeeded" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
});
|
|
});
|