Files
the-pet-loft/apps/storefront/src/lib/checkout/useShippingRate.test.tsx
ianshaloom 9013905d01
Some checks failed
CI / Lint, Typecheck & Test (push) Failing after 2m13s
fix: resolve CI test failures (carts, stripe, shipping, scaffold, cart session)
- carts.test: add required product fields (parentCategorySlug, childCategorySlug)
  and variant fields (weight, weightUnit)
- stripeActions.test: use price in cents (2499) for variant/cart and expect
  unit_amount: 2499 in line_items assertion
- useShippingRate.test: expect fallback error message for plain Error rejections
- scaffold.test: enable @ alias in root vitest.config for storefront imports
- useCartSession.test: mock useConvexAuth instead of ConvexProviderWithClerk
  for reliable unit tests

Made-with: Cursor
2026-03-08 01:05:51 +03:00

271 lines
7.8 KiB
TypeScript

/**
* @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<CheckoutShippingRateResult>((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");
});
});