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,331 @@
import { convexTest } from "convex-test";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
import type { Id } from "./_generated/dataModel";
const modules = import.meta.glob("./**/*.ts");
const identity = {
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
};
const baseAddress = {
firstName: "Alice",
lastName: "Smith",
phone: "+447911123456",
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123");
vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "addr_warehouse_001");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function mockShippoShipmentResponse(overrides?: {
shipmentId?: string;
rates?: Array<{
object_id: string;
provider: string;
servicelevel: { name: string; token: string };
amount: string;
currency: string;
estimated_days: number;
duration_terms: string;
arrives_by?: string | null;
carrier_account: string;
}>;
}) {
const defaultRates = [
{
object_id: "rate_001",
provider: "DPD UK",
servicelevel: { name: "Next Day", token: "dpd_uk_next_day" },
amount: "5.50",
currency: "GBP",
estimated_days: 1,
duration_terms: "1-2 business days",
arrives_by: null,
carrier_account: "ca_dpd_001",
},
{
object_id: "rate_002",
provider: "UPS",
servicelevel: { name: "Standard", token: "ups_standard" },
amount: "7.99",
currency: "GBP",
estimated_days: 3,
duration_terms: "3-5 business days",
carrier_account: "ca_ups_001",
},
];
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
object_id: overrides?.shipmentId ?? "shp_test_123",
rates: overrides?.rates ?? defaultRates,
}),
});
}
async function setupFullCheckoutContext(
t: ReturnType<typeof convexTest>,
overrides?: { stockQuantity?: number; price?: number; isActive?: boolean },
) {
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
additionalInformation: "Flat 2",
isDefault: true,
isValidated: true,
});
let productId: Id<"products">;
let variantId: Id<"productVariants">;
await t.run(async (ctx) => {
const categoryId = await ctx.db.insert("categories", {
name: "Dog Food",
slug: "dog-food",
});
productId = await ctx.db.insert("products", {
name: "Premium Kibble",
slug: "premium-kibble",
status: "active",
categoryId,
tags: [],
parentCategorySlug: "dogs",
childCategorySlug: "dog-food",
});
variantId = await ctx.db.insert("productVariants", {
productId,
name: "1kg Bag",
sku: "PK-001",
price: overrides?.price ?? 2499,
stockQuantity: overrides?.stockQuantity ?? 50,
isActive: overrides?.isActive ?? true,
weight: 1000,
weightUnit: "g",
length: 30,
width: 20,
height: 10,
dimensionUnit: "cm",
});
await ctx.db.insert("productImages", {
productId,
url: "https://example.com/kibble.jpg",
position: 0,
});
await ctx.db.insert("carts", {
userId,
items: [
{
productId,
variantId,
quantity: 2,
price: overrides?.price ?? 2499,
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
return { userId, addressId, productId: productId!, variantId: variantId!, asA };
}
describe("checkoutActions.getShippingRate", () => {
it("throws when user is not authenticated", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await expect(
t.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/signed in/i);
});
it("throws when cart is empty", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/cart is empty/i);
});
it("throws when cart has blocking issues (out of stock)", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stockQuantity: 0,
});
mockShippoShipmentResponse();
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/cart has issues/i);
});
it("returns a complete ShippingRateResult on success", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse();
const result = await asA.action(api.checkoutActions.getShippingRate, {
addressId,
});
expect(result.shipmentObjectId).toBe("shp_test_123");
expect(result.selectedRate.provider).toBe("DPD UK");
expect(result.selectedRate.serviceName).toBe("Next Day");
expect(result.selectedRate.serviceToken).toBe("dpd_uk_next_day");
expect(result.selectedRate.amount).toBe(5.5);
expect(result.selectedRate.currency).toBe("GBP");
expect(result.selectedRate.estimatedDays).toBe(1);
expect(result.selectedRate.durationTerms).toBe("1-2 business days");
expect(result.selectedRate.carrierAccount).toBe("ca_dpd_001");
expect(result.alternativeRates).toHaveLength(1);
expect(result.alternativeRates[0].provider).toBe("UPS");
expect(result.cartSubtotal).toBe(2499 * 2);
expect(result.shippingTotal).toBe(5.5);
expect(result.orderTotal).toBe(2499 * 2 + 5.5);
});
it("sends correct Shippo request with mapped address fields", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse();
await asA.action(api.checkoutActions.getShippingRate, { addressId });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe("https://api.goshippo.com/shipments/");
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.address_from).toBe("addr_warehouse_001");
expect(body.address_to.name).toBe("Alice Smith");
expect(body.address_to.street1).toBe("10 Downing Street");
expect(body.address_to.street2).toBe("Flat 2");
expect(body.address_to.city).toBe("London");
expect(body.address_to.zip).toBe("SW1A 2AA");
expect(body.address_to.country).toBe("GB");
expect(body.address_to.phone).toBe("+447911123456");
expect(body.async).toBe(false);
expect(body.parcels).toHaveLength(1);
expect(body.parcels[0].weight).toBe("2000");
expect(body.parcels[0].mass_unit).toBe("g");
});
it("throws when SHIPPO_SOURCE_ADDRESS_ID env var is missing", async () => {
vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "");
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/missing source address/i);
});
it("throws when Shippo API returns no rates", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse({ rates: [] });
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/no shipping rates available/i);
});
it("falls back to non-preferred carriers when no preferred carriers in response", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse({
rates: [
{
object_id: "rate_non_pref",
provider: "Royal Mail",
servicelevel: { name: "2nd Class", token: "royal_mail_2nd" },
amount: "3.00",
currency: "GBP",
estimated_days: 3,
duration_terms: "2-4 business days",
arrives_by: null,
carrier_account: "ca_rm_001",
},
],
});
const result = await asA.action(api.checkoutActions.getShippingRate, {
addressId,
});
expect(result.selectedRate.provider).toBe("Royal Mail");
expect(result.selectedRate.amount).toBe(3.0);
expect(warnSpy).toHaveBeenCalledWith(
"No preferred carriers returned rates. Falling back to all carriers.",
);
warnSpy.mockRestore();
});
it("correctly computes parcel from cart items with dimensions", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse();
await asA.action(api.checkoutActions.getShippingRate, { addressId });
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
const parcel = body.parcels[0];
expect(parcel.length).toBe("30");
expect(parcel.width).toBe("20");
expect(parcel.height).toBe("20");
expect(parcel.distance_unit).toBe("cm");
});
});