feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform: - apps/storefront: full customer-facing Next.js app with HeroUI (cart, checkout, orders, wishlist, product detail, shop, search, auth) - convex/: serverless backend with schema, queries, mutations, actions, HTTP routes, Stripe/Shippo integrations, and co-located tests - packages/types, packages/utils, packages/convex: shared workspace packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
268
apps/storefront/src/lib/checkout/useShippingRate.test.tsx
Normal file
268
apps/storefront/src/lib/checkout/useShippingRate.test.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @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(
|
||||
"Shipping configuration is incomplete",
|
||||
);
|
||||
});
|
||||
|
||||
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("Network timeout");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user