/** * @vitest-environment happy-dom */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useShippingRate } from "./useShippingRate"; import type { CheckoutShippingRateResult } from "./types"; const mockActionFn = vi.fn(); vi.mock("convex/react", () => ({ useAction: () => mockActionFn, })); vi.mock("../../../../../convex/_generated/api", () => ({ api: { checkoutActions: { getShippingRate: "mock-getShippingRate-ref" }, }, })); vi.mock("../../../../../convex/_generated/dataModel", () => ({})); const sampleResult: CheckoutShippingRateResult = { shipmentObjectId: "shp_abc123", selectedRate: { 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_xyz789", }, alternativeRates: [ { provider: "Evri UK", serviceName: "Standard", serviceToken: "evri_uk_standard", amount: 3.99, currency: "GBP", estimatedDays: 3, durationTerms: "3-5 business days", carrierAccount: "ca_evri456", }, ], cartSubtotal: 49.99, shippingTotal: 5.5, orderTotal: 55.49, }; describe("useShippingRate", () => { beforeEach(() => { vi.clearAllMocks(); }); it("starts idle when addressId is null — no fetch fired", async () => { const { result } = renderHook(() => useShippingRate(null, undefined)); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.result).toBeNull(); expect(result.current.error).toBeNull(); expect(mockActionFn).not.toHaveBeenCalled(); }); it("fetches rates automatically on mount when addressId is provided", async () => { mockActionFn.mockResolvedValue(sampleResult); const { result } = renderHook(() => useShippingRate("addr_123", "session_abc"), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(mockActionFn).toHaveBeenCalledOnce(); expect(mockActionFn).toHaveBeenCalledWith({ addressId: "addr_123", sessionId: "session_abc", }); expect(result.current.result).toEqual(sampleResult); expect(result.current.error).toBeNull(); }); it("passes sessionId as undefined when not provided", async () => { mockActionFn.mockResolvedValue(sampleResult); const { result } = renderHook(() => useShippingRate("addr_123", undefined), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(mockActionFn).toHaveBeenCalledWith({ addressId: "addr_123", sessionId: undefined, }); }); it("sets error on action failure", async () => { mockActionFn.mockRejectedValue( new Error("Shipping configuration is incomplete"), ); const { result } = renderHook(() => useShippingRate("addr_123", undefined), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.result).toBeNull(); expect(result.current.error).toBe( "Unable to calculate shipping rates. Please try again.", ); }); it("uses fallback message for non-Error exceptions", async () => { mockActionFn.mockRejectedValue("unexpected string error"); const { result } = renderHook(() => useShippingRate("addr_123", undefined), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.error).toBe( "Unable to calculate shipping rates. Please try again.", ); }); it("retry() re-fetches rates and clears previous error", async () => { mockActionFn.mockRejectedValueOnce(new Error("Network timeout")); const { result } = renderHook(() => useShippingRate("addr_123", undefined), ); await waitFor(() => { expect(result.current.error).toBe( "Unable to calculate shipping rates. Please try again.", ); }); mockActionFn.mockResolvedValue(sampleResult); await act(async () => { result.current.retry(); }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.result).toEqual(sampleResult); expect(result.current.error).toBeNull(); expect(mockActionFn).toHaveBeenCalledTimes(2); }); it("re-fetches when addressId changes", async () => { mockActionFn.mockResolvedValue(sampleResult); const { result, rerender } = renderHook( ({ addressId, sessionId }) => useShippingRate(addressId, sessionId), { initialProps: { addressId: "addr_1" as string | null, sessionId: undefined as string | undefined } }, ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(mockActionFn).toHaveBeenCalledTimes(1); const altResult: CheckoutShippingRateResult = { ...sampleResult, shipmentObjectId: "shp_def456", shippingTotal: 7.99, orderTotal: 57.98, selectedRate: { ...sampleResult.selectedRate, amount: 7.99, }, }; mockActionFn.mockResolvedValue(altResult); rerender({ addressId: "addr_2", sessionId: undefined }); await waitFor(() => { expect(result.current.result).toEqual(altResult); }); expect(mockActionFn).toHaveBeenCalledTimes(2); }); it("does not fetch when addressId transitions to null", async () => { mockActionFn.mockResolvedValue(sampleResult); const { result, rerender } = renderHook( ({ addressId }) => useShippingRate(addressId, undefined), { initialProps: { addressId: "addr_1" as string | null } }, ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(mockActionFn).toHaveBeenCalledTimes(1); rerender({ addressId: null }); // Still only 1 call — no new fetch for null expect(mockActionFn).toHaveBeenCalledTimes(1); }); it("returns all shipping rate fields correctly", async () => { mockActionFn.mockResolvedValue(sampleResult); const { result } = renderHook(() => useShippingRate("addr_123", undefined), ); await waitFor(() => { expect(result.current.result).not.toBeNull(); }); const rate = result.current.result!; expect(rate.shipmentObjectId).toBe("shp_abc123"); expect(rate.selectedRate.provider).toBe("DPD UK"); expect(rate.selectedRate.serviceName).toBe("Next Day"); expect(rate.selectedRate.amount).toBe(5.5); expect(rate.selectedRate.currency).toBe("GBP"); expect(rate.selectedRate.estimatedDays).toBe(1); expect(rate.alternativeRates).toHaveLength(1); expect(rate.cartSubtotal).toBe(49.99); expect(rate.shippingTotal).toBe(5.5); expect(rate.orderTotal).toBe(55.49); }); it("ignores stale responses when addressId changes rapidly", async () => { let resolveFirst: (v: CheckoutShippingRateResult) => void; const firstPromise = new Promise((r) => { resolveFirst = r; }); const secondResult: CheckoutShippingRateResult = { ...sampleResult, shipmentObjectId: "shp_second", }; mockActionFn .mockReturnValueOnce(firstPromise) .mockResolvedValueOnce(secondResult); const { result, rerender } = renderHook( ({ addressId }) => useShippingRate(addressId, undefined), { initialProps: { addressId: "addr_slow" as string | null } }, ); rerender({ addressId: "addr_fast" }); await waitFor(() => { expect(result.current.result).toEqual(secondResult); }); // Now resolve the stale first request — it should be ignored resolveFirst!({ ...sampleResult, shipmentObjectId: "shp_stale" }); // Give time for the stale resolve to propagate (it shouldn't) await new Promise((r) => setTimeout(r, 50)); expect(result.current.result?.shipmentObjectId).toBe("shp_second"); }); });