test: improve branch coverage for billing.ts and keys.ts
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 17m26s
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 17m26s
billing.ts branches: 78.66% → 82.66% - isDocFastSubscription expanded product object (not just string) - isDocFastSubscription error handling path - getOrCreateProPrice: new product + price creation - getOrCreateProPrice: existing product, no active prices keys.ts: - createProKey UPSERT ON CONFLICT path (DB-only key) - downgradeByCustomer customer not found in cache or DB - updateKeyEmail DB fallback not-found path - updateEmailByCustomer DB fallback not-found path 14 new tests, all 743 passing.
This commit is contained in:
parent
2bdf93d09f
commit
f5ec837e20
2 changed files with 482 additions and 0 deletions
260
src/__tests__/billing-branch-coverage.test.ts
Normal file
260
src/__tests__/billing-branch-coverage.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
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 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
222
src/__tests__/keys-branch-coverage.test.ts
Normal file
222
src/__tests__/keys-branch-coverage.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Unmock keys service — test the real implementation
|
||||||
|
vi.unmock("../services/keys.js");
|
||||||
|
|
||||||
|
vi.mock("../services/db.js", () => ({
|
||||||
|
default: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() },
|
||||||
|
pool: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() },
|
||||||
|
queryWithRetry: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
|
||||||
|
connectWithRetry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
initDatabase: vi.fn().mockResolvedValue(undefined),
|
||||||
|
cleanupStaleData: vi.fn(),
|
||||||
|
isTransientError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/logger.js", () => ({
|
||||||
|
default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { queryWithRetry } from "../services/db.js";
|
||||||
|
import { createProKey, downgradeByCustomer, updateKeyEmail, updateEmailByCustomer } from "../services/keys.js";
|
||||||
|
|
||||||
|
const mockQuery = vi.mocked(queryWithRetry);
|
||||||
|
|
||||||
|
describe("Keys Branch Coverage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createProKey - UPSERT conflict path (line 142)", () => {
|
||||||
|
it("should return existing key when stripe_customer_id already exists in DB but NOT in cache", async () => {
|
||||||
|
// Scenario: Another pod created a key for this customer, so it's in DB but not in our cache
|
||||||
|
// The UPSERT will hit ON CONFLICT and return the existing key via RETURNING clause
|
||||||
|
|
||||||
|
const existingKey = {
|
||||||
|
key: "df_pro_existing_abc",
|
||||||
|
tier: "pro",
|
||||||
|
email: "existing@test.com",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_existing",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock: UPSERT returns the existing key (ON CONFLICT triggered)
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [existingKey], rowCount: 1 } as any);
|
||||||
|
|
||||||
|
const result = await createProKey("new@test.com", "cus_existing");
|
||||||
|
|
||||||
|
// Should return the existing key
|
||||||
|
expect(result.key).toBe("df_pro_existing_abc");
|
||||||
|
expect(result.email).toBe("existing@test.com"); // Original email, not the new one
|
||||||
|
expect(result.stripeCustomerId).toBe("cus_existing");
|
||||||
|
expect(result.tier).toBe("pro");
|
||||||
|
|
||||||
|
// Verify UPSERT was called
|
||||||
|
const upsertCall = mockQuery.mock.calls.find(
|
||||||
|
(c) => typeof c[0] === "string" && c[0].includes("ON CONFLICT")
|
||||||
|
);
|
||||||
|
expect(upsertCall).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle conflict when inserting new key with existing customer ID", async () => {
|
||||||
|
// First call: load empty cache
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
||||||
|
|
||||||
|
const { loadKeys } = await import("../services/keys.js");
|
||||||
|
await loadKeys();
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const conflictingKey = {
|
||||||
|
key: "df_pro_conflict_xyz",
|
||||||
|
tier: "pro",
|
||||||
|
email: "conflict@test.com",
|
||||||
|
created_at: "2025-12-31T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_conflict",
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPSERT returns existing key on conflict
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [conflictingKey], rowCount: 1 } as any);
|
||||||
|
|
||||||
|
const result = await createProKey("different-email@test.com", "cus_conflict");
|
||||||
|
|
||||||
|
expect(result.key).toBe("df_pro_conflict_xyz");
|
||||||
|
expect(result.email).toBe("conflict@test.com"); // Original, not the new email
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("downgradeByCustomer - customer not found (lines 153-155)", () => {
|
||||||
|
it("should return false when customer is NOT in cache AND NOT in DB", async () => {
|
||||||
|
// Mock: SELECT query returns empty (customer not in DB)
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
||||||
|
|
||||||
|
const result = await downgradeByCustomer("cus_nonexistent");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
// Verify SELECT was called
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("SELECT"),
|
||||||
|
expect.arrayContaining(["cus_nonexistent"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify UPDATE was NOT called (no point updating a non-existent key)
|
||||||
|
const updateCalls = mockQuery.mock.calls.filter((c) =>
|
||||||
|
(c[0] as string).includes("UPDATE")
|
||||||
|
);
|
||||||
|
expect(updateCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for completely unknown stripe customer ID", async () => {
|
||||||
|
// Load empty cache first
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
||||||
|
const { loadKeys } = await import("../services/keys.js");
|
||||||
|
await loadKeys();
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock: DB also doesn't have this customer
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
||||||
|
|
||||||
|
const result = await downgradeByCustomer("cus_unknown_12345");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateKeyEmail - DB fallback path (line 175)", () => {
|
||||||
|
it("should return false when key is NOT in cache AND NOT in DB", async () => {
|
||||||
|
// Mock: SELECT query returns empty (key not in DB)
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
||||||
|
|
||||||
|
const result = await updateKeyEmail("df_pro_nonexistent", "new@test.com");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
// Verify SELECT was called
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("SELECT"),
|
||||||
|
expect.arrayContaining(["df_pro_nonexistent"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify UPDATE was NOT called
|
||||||
|
const updateCalls = mockQuery.mock.calls.filter((c) =>
|
||||||
|
(c[0] as string).includes("UPDATE")
|
||||||
|
);
|
||||||
|
expect(updateCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update and cache when key exists in DB but not in cache", async () => {
|
||||||
|
const dbKey = {
|
||||||
|
key: "df_pro_db_only",
|
||||||
|
tier: "pro",
|
||||||
|
email: "old@test.com",
|
||||||
|
created_at: "2026-01-15T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_db_only",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock: SELECT returns the key from DB
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [dbKey], rowCount: 1 } as any)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); // UPDATE success
|
||||||
|
|
||||||
|
const result = await updateKeyEmail("df_pro_db_only", "updated@test.com");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify UPDATE was called
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("UPDATE"),
|
||||||
|
expect.arrayContaining(["updated@test.com", "df_pro_db_only"])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmailByCustomer - DB fallback path (line 175)", () => {
|
||||||
|
it("should return false when customer is NOT in cache AND NOT in DB", async () => {
|
||||||
|
// Mock: SELECT query returns empty
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
||||||
|
|
||||||
|
const result = await updateEmailByCustomer("cus_nonexistent", "new@test.com");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
// Verify SELECT was called
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("SELECT"),
|
||||||
|
expect.arrayContaining(["cus_nonexistent"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify UPDATE was NOT called
|
||||||
|
const updateCalls = mockQuery.mock.calls.filter((c) =>
|
||||||
|
(c[0] as string).includes("UPDATE")
|
||||||
|
);
|
||||||
|
expect(updateCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update and cache when customer exists in DB but not in cache", async () => {
|
||||||
|
const dbKey = {
|
||||||
|
key: "df_pro_customer_db",
|
||||||
|
tier: "pro",
|
||||||
|
email: "oldcustomer@test.com",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_db_customer",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock: SELECT returns the key
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [dbKey], rowCount: 1 } as any)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); // UPDATE success
|
||||||
|
|
||||||
|
const result = await updateEmailByCustomer("cus_db_customer", "newcustomer@test.com");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify UPDATE was called with correct params
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("UPDATE"),
|
||||||
|
expect.arrayContaining(["newcustomer@test.com", "cus_db_customer"])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue