Some checks failed
CI / Lint, Typecheck & Test (push) Failing after 2m13s
- 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
271 lines
7.8 KiB
TypeScript
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");
|
|
});
|
|
});
|