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 modules = import.meta.glob("./**/*.ts"); const identity = { name: "Alice", 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", }; let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123"); vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "addr_warehouse_001"); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); function mockShippoShipmentResponse(overrides?: { shipmentId?: string; rates?: Array<{ object_id: string; provider: string; servicelevel: { name: string; token: string }; amount: string; currency: string; estimated_days: number; duration_terms: string; arrives_by?: string | null; carrier_account: string; }>; }) { const defaultRates = [ { object_id: "rate_001", provider: "DPD UK", servicelevel: { name: "Next Day", token: "dpd_uk_next_day" }, amount: "5.50", currency: "GBP", estimated_days: 1, duration_terms: "1-2 business days", arrives_by: null, carrier_account: "ca_dpd_001", }, { object_id: "rate_002", provider: "UPS", servicelevel: { name: "Standard", token: "ups_standard" }, amount: "7.99", currency: "GBP", estimated_days: 3, duration_terms: "3-5 business days", carrier_account: "ca_ups_001", }, ]; fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ object_id: overrides?.shipmentId ?? "shp_test_123", rates: overrides?.rates ?? defaultRates, }), }); } async function setupFullCheckoutContext( t: ReturnType, overrides?: { stockQuantity?: number; price?: number; isActive?: boolean }, ) { const asA = t.withIdentity(identity); const userId = await asA.mutation(api.users.store, {}); 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 }; } describe("checkoutActions.getShippingRate", () => { 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.checkoutActions.getShippingRate, { addressId }), ).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.checkoutActions.getShippingRate, { addressId }), ).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, }); mockShippoShipmentResponse(); await expect( asA.action(api.checkoutActions.getShippingRate, { addressId }), ).rejects.toThrow(/cart has issues/i); }); it("returns a complete ShippingRateResult on success", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); mockShippoShipmentResponse(); const result = await asA.action(api.checkoutActions.getShippingRate, { addressId, }); expect(result.shipmentObjectId).toBe("shp_test_123"); expect(result.selectedRate.provider).toBe("DPD UK"); expect(result.selectedRate.serviceName).toBe("Next Day"); expect(result.selectedRate.serviceToken).toBe("dpd_uk_next_day"); expect(result.selectedRate.amount).toBe(5.5); expect(result.selectedRate.currency).toBe("GBP"); expect(result.selectedRate.estimatedDays).toBe(1); expect(result.selectedRate.durationTerms).toBe("1-2 business days"); expect(result.selectedRate.carrierAccount).toBe("ca_dpd_001"); expect(result.alternativeRates).toHaveLength(1); expect(result.alternativeRates[0].provider).toBe("UPS"); expect(result.cartSubtotal).toBe(2499 * 2); expect(result.shippingTotal).toBe(5.5); expect(result.orderTotal).toBe(2499 * 2 + 5.5); }); it("sends correct Shippo request with mapped address fields", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); mockShippoShipmentResponse(); await asA.action(api.checkoutActions.getShippingRate, { addressId }); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe("https://api.goshippo.com/shipments/"); expect(opts.method).toBe("POST"); const body = JSON.parse(opts.body); expect(body.address_from).toBe("addr_warehouse_001"); expect(body.address_to.name).toBe("Alice Smith"); expect(body.address_to.street1).toBe("10 Downing Street"); expect(body.address_to.street2).toBe("Flat 2"); expect(body.address_to.city).toBe("London"); expect(body.address_to.zip).toBe("SW1A 2AA"); expect(body.address_to.country).toBe("GB"); expect(body.address_to.phone).toBe("+447911123456"); expect(body.async).toBe(false); expect(body.parcels).toHaveLength(1); expect(body.parcels[0].weight).toBe("2000"); expect(body.parcels[0].mass_unit).toBe("g"); }); it("throws when SHIPPO_SOURCE_ADDRESS_ID env var is missing", async () => { vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", ""); const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); await expect( asA.action(api.checkoutActions.getShippingRate, { addressId }), ).rejects.toThrow(/missing source address/i); }); it("throws when Shippo API returns no rates", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); mockShippoShipmentResponse({ rates: [] }); await expect( asA.action(api.checkoutActions.getShippingRate, { addressId }), ).rejects.toThrow(/no shipping rates available/i); }); it("falls back to non-preferred carriers when no preferred carriers in response", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); mockShippoShipmentResponse({ rates: [ { object_id: "rate_non_pref", provider: "Royal Mail", servicelevel: { name: "2nd Class", token: "royal_mail_2nd" }, amount: "3.00", currency: "GBP", estimated_days: 3, duration_terms: "2-4 business days", arrives_by: null, carrier_account: "ca_rm_001", }, ], }); const result = await asA.action(api.checkoutActions.getShippingRate, { addressId, }); expect(result.selectedRate.provider).toBe("Royal Mail"); expect(result.selectedRate.amount).toBe(3.0); expect(warnSpy).toHaveBeenCalledWith( "No preferred carriers returned rates. Falling back to all carriers.", ); warnSpy.mockRestore(); }); it("correctly computes parcel from cart items with dimensions", async () => { const t = convexTest(schema, modules); const { addressId, asA } = await setupFullCheckoutContext(t); mockShippoShipmentResponse(); await asA.action(api.checkoutActions.getShippingRate, { addressId }); const body = JSON.parse(fetchSpy.mock.calls[0][1].body); const parcel = body.parcels[0]; expect(parcel.length).toBe("30"); expect(parcel.width).toBe("20"); expect(parcel.height).toBe("20"); expect(parcel.distance_unit).toBe("cm"); }); });