import { convexTest } from "convex-test"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { api } from "./_generated/api"; import schema from "./schema"; import type { Id } from "./_generated/dataModel"; const { mockCustomersCreate, mockSessionsCreate, mockSessionsRetrieve } = vi.hoisted(() => ({ mockCustomersCreate: vi.fn(), mockSessionsCreate: vi.fn(), mockSessionsRetrieve: vi.fn(), })); vi.mock("stripe", () => { return { default: class Stripe { customers = { create: mockCustomersCreate }; checkout = { sessions: { create: mockSessionsCreate, retrieve: mockSessionsRetrieve, }, }; }, }; }); const modules = import.meta.glob("./**/*.ts"); const identity = { name: "Alice Smith", email: "alice@example.com", subject: "clerk_alice_123", }; const baseAddress = { firstName: "Alice", lastName: "Smith", phone: "+447911123456", addressLine1: "10 Downing Street", city: "London", postalCode: "SW1A 2AA", country: "GB", }; const shippingRate = { provider: "DPD UK", serviceName: "Next Day", serviceToken: "dpd_uk_next_day", amount: 5.5, currency: "GBP", estimatedDays: 1, durationTerms: "1-2 business days", carrierAccount: "ca_dpd_001", }; beforeEach(() => { vi.stubEnv("STRIPE_SECRET_KEY", "sk_test_fake_key"); vi.stubEnv("STOREFRONT_URL", "http://localhost:3000"); mockCustomersCreate.mockResolvedValue({ id: "cus_new_123" }); mockSessionsCreate.mockResolvedValue({ id: "cs_test_session_123", client_secret: "cs_test_secret_abc", status: "open", payment_status: "unpaid", }); mockSessionsRetrieve.mockResolvedValue({ id: "cs_test_session_123", status: "complete", payment_status: "paid", customer_details: { email: "alice@example.com" }, }); }); afterEach(() => { vi.unstubAllEnvs(); mockCustomersCreate.mockReset(); mockSessionsCreate.mockReset(); mockSessionsRetrieve.mockReset(); }); async function setupFullCheckoutContext( t: ReturnType, overrides?: { stockQuantity?: number; price?: number; isActive?: boolean; stripeCustomerId?: string; }, ) { const asA = t.withIdentity(identity); const userId = await asA.mutation(api.users.store, {}); if (overrides?.stripeCustomerId) { await t.run(async (ctx) => { await ctx.db.patch(userId, { stripeCustomerId: overrides.stripeCustomerId, }); }); } const addressId = await asA.mutation(api.addresses.add, { ...baseAddress, type: "shipping", additionalInformation: "Flat 2", isDefault: true, isValidated: true, }); let productId: Id<"products">; let variantId: Id<"productVariants">; await t.run(async (ctx) => { const categoryId = await ctx.db.insert("categories", { name: "Dog Food", slug: "dog-food", }); productId = await ctx.db.insert("products", { name: "Premium Kibble", slug: "premium-kibble", status: "active", categoryId, tags: [], parentCategorySlug: "dogs", childCategorySlug: "dog-food", }); variantId = await ctx.db.insert("productVariants", { productId, name: "1kg Bag", sku: "PK-001", price: overrides?.price ?? 2499, stockQuantity: overrides?.stockQuantity ?? 50, isActive: overrides?.isActive ?? true, weight: 1000, weightUnit: "g", length: 30, width: 20, height: 10, dimensionUnit: "cm", }); await ctx.db.insert("productImages", { productId, url: "https://example.com/kibble.jpg", position: 0, }); await ctx.db.insert("carts", { userId, items: [ { productId, variantId, quantity: 2, price: overrides?.price ?? 2499, }, ], createdAt: Date.now(), updatedAt: Date.now(), expiresAt: Date.now() + 86400000, }); }); return { userId, addressId, productId: productId!, variantId: variantId!, asA, }; } // ─── createCheckoutSession ─────────────────────────────────────────────────── describe("stripeActions.createCheckoutSession", () => { it("throws when user is not authenticated", async () => { const t = convexTest(schema, modules); const asA = t.withIdentity(identity); await asA.mutation(api.users.store, {}); const addressId = await asA.mutation(api.addresses.add, { ...baseAddress, type: "shipping", isDefault: true, }); await expect( t.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }), ).rejects.toThrow(/signed in/i); }); it("throws when cart is empty", async () => { const t = convexTest(schema, modules); const asA = t.withIdentity(identity); await asA.mutation(api.users.store, {}); const addressId = await asA.mutation(api.addresses.add, { ...baseAddress, type: "shipping", isDefault: true, }); await expect( asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }), ).rejects.toThrow(/cart is empty/i); }); it("throws when cart has blocking issues (out of stock)", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t, { stockQuantity: 0, }); await expect( asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }), ).rejects.toThrow(/cart has issues/i); }); it("creates a Stripe customer when user has none and patches the user", async () => { const t = convexTest(schema, modules); const { userId, addressId, asA } = await setupFullCheckoutContext(t); mockCustomersCreate.mockResolvedValue({ id: "cus_fresh_456" }); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); expect(mockCustomersCreate).toHaveBeenCalledOnce(); expect(mockCustomersCreate).toHaveBeenCalledWith({ email: "alice@example.com", name: "Alice Smith", metadata: { convexUserId: userId }, }); const updatedUser = await t.run(async (ctx) => ctx.db.get(userId)); expect(updatedUser?.stripeCustomerId).toBe("cus_fresh_456"); }); it("reuses existing Stripe customer and does not create a new one", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t, { stripeCustomerId: "cus_existing_789", }); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); expect(mockCustomersCreate).not.toHaveBeenCalled(); }); it("returns clientSecret on success", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); const result = await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); expect(result).toEqual({ clientSecret: "cs_test_secret_abc" }); }); it("builds correct line items from cart", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); const createCall = mockSessionsCreate.mock.calls[0][0]; expect(createCall.line_items).toHaveLength(1); expect(createCall.line_items[0]).toEqual({ price_data: { currency: "gbp", product_data: { name: "Premium Kibble — 1kg Bag" }, unit_amount: 2499, }, quantity: 2, }); }); it("passes correct shipping options with amount in pence", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); const createCall = mockSessionsCreate.mock.calls[0][0]; expect(createCall.shipping_options).toHaveLength(1); const rateData = createCall.shipping_options[0].shipping_rate_data; expect(rateData.fixed_amount.amount).toBe(550); expect(rateData.fixed_amount.currency).toBe("gbp"); expect(rateData.display_name).toBe("DPD UK — Next Day"); expect(rateData.delivery_estimate.minimum).toEqual({ unit: "business_day", value: 1, }); }); it("passes correct metadata including all shipping fields", async () => { const t = convexTest(schema, modules); const { userId, addressId, asA } = await setupFullCheckoutContext(t); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); const createCall = mockSessionsCreate.mock.calls[0][0]; expect(createCall.metadata).toEqual({ convexUserId: userId, addressId, shipmentObjectId: "shp_test_123", shippingMethod: "DPD UK — Next Day", shippingServiceCode: "dpd_uk_next_day", carrier: "DPD UK", carrierAccount: "ca_dpd_001", }); }); it("creates session with ui_mode custom and correct return_url", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); const createCall = mockSessionsCreate.mock.calls[0][0]; expect(createCall.mode).toBe("payment"); expect(createCall.ui_mode).toBe("custom"); expect(createCall.return_url).toBe( "http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID}", ); }); it("passes Stripe customer ID to session creation", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t, { stripeCustomerId: "cus_existing_789", }); await asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }); const createCall = mockSessionsCreate.mock.calls[0][0]; expect(createCall.customer).toBe("cus_existing_789"); }); it("throws when STOREFRONT_URL env var is missing", async () => { vi.stubEnv("STOREFRONT_URL", ""); const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); await expect( asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }), ).rejects.toThrow(/STOREFRONT_URL/i); }); it("throws when Stripe returns no client_secret", async () => { mockSessionsCreate.mockResolvedValue({ id: "cs_test_no_secret", client_secret: null, }); const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); await expect( asA.action(api.stripeActions.createCheckoutSession, { addressId, shipmentObjectId: "shp_test_123", shippingRate, }), ).rejects.toThrow(/client_secret/i); }); }); // ─── getCheckoutSessionStatus ──────────────────────────────────────────────── describe("stripeActions.getCheckoutSessionStatus", () => { it("returns complete status with customer email", async () => { mockSessionsRetrieve.mockResolvedValue({ status: "complete", payment_status: "paid", customer_details: { email: "alice@example.com" }, }); const t = convexTest(schema, modules); const result = await t.action(api.stripeActions.getCheckoutSessionStatus, { sessionId: "cs_test_session_123", }); expect(result).toEqual({ status: "complete", paymentStatus: "paid", customerEmail: "alice@example.com", }); expect(mockSessionsRetrieve).toHaveBeenCalledWith("cs_test_session_123"); }); it("returns expired status", async () => { mockSessionsRetrieve.mockResolvedValue({ status: "expired", payment_status: "unpaid", customer_details: null, }); const t = convexTest(schema, modules); const result = await t.action(api.stripeActions.getCheckoutSessionStatus, { sessionId: "cs_expired_456", }); expect(result).toEqual({ status: "expired", paymentStatus: "unpaid", customerEmail: null, }); }); it("returns open status when payment not completed", async () => { mockSessionsRetrieve.mockResolvedValue({ status: "open", payment_status: "unpaid", customer_details: { email: "alice@example.com" }, }); const t = convexTest(schema, modules); const result = await t.action(api.stripeActions.getCheckoutSessionStatus, { sessionId: "cs_open_789", }); expect(result).toEqual({ status: "open", paymentStatus: "unpaid", customerEmail: "alice@example.com", }); }); it("returns null email when customer_details is absent", async () => { mockSessionsRetrieve.mockResolvedValue({ status: "complete", payment_status: "paid", customer_details: undefined, }); const t = convexTest(schema, modules); const result = await t.action(api.stripeActions.getCheckoutSessionStatus, { sessionId: "cs_no_email", }); expect(result.customerEmail).toBeNull(); }); });