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:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View 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");
});
});