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,490 @@
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 { mockCustomersCreate, mockSessionsCreate, mockSessionsRetrieve } =
vi.hoisted(() => ({
mockCustomersCreate: vi.fn(),
mockSessionsCreate: vi.fn(),
mockSessionsRetrieve: vi.fn(),
}));
vi.mock("stripe", () => {
return {
default: class Stripe {
customers = { create: mockCustomersCreate };
checkout = {
sessions: {
create: mockSessionsCreate,
retrieve: mockSessionsRetrieve,
},
};
},
};
});
const modules = import.meta.glob("./**/*.ts");
const identity = {
name: "Alice Smith",
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",
};
const shippingRate = {
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_dpd_001",
};
beforeEach(() => {
vi.stubEnv("STRIPE_SECRET_KEY", "sk_test_fake_key");
vi.stubEnv("STOREFRONT_URL", "http://localhost:3000");
mockCustomersCreate.mockResolvedValue({ id: "cus_new_123" });
mockSessionsCreate.mockResolvedValue({
id: "cs_test_session_123",
client_secret: "cs_test_secret_abc",
status: "open",
payment_status: "unpaid",
});
mockSessionsRetrieve.mockResolvedValue({
id: "cs_test_session_123",
status: "complete",
payment_status: "paid",
customer_details: { email: "alice@example.com" },
});
});
afterEach(() => {
vi.unstubAllEnvs();
mockCustomersCreate.mockReset();
mockSessionsCreate.mockReset();
mockSessionsRetrieve.mockReset();
});
async function setupFullCheckoutContext(
t: ReturnType<typeof convexTest>,
overrides?: {
stockQuantity?: number;
price?: number;
isActive?: boolean;
stripeCustomerId?: string;
},
) {
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
if (overrides?.stripeCustomerId) {
await t.run(async (ctx) => {
await ctx.db.patch(userId, {
stripeCustomerId: overrides.stripeCustomerId,
});
});
}
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 ?? 24.99,
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 ?? 24.99,
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
return {
userId,
addressId,
productId: productId!,
variantId: variantId!,
asA,
};
}
// ─── createCheckoutSession ───────────────────────────────────────────────────
describe("stripeActions.createCheckoutSession", () => {
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.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).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.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).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,
});
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/cart has issues/i);
});
it("creates a Stripe customer when user has none and patches the user", async () => {
const t = convexTest(schema, modules);
const { userId, addressId, asA } = await setupFullCheckoutContext(t);
mockCustomersCreate.mockResolvedValue({ id: "cus_fresh_456" });
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
expect(mockCustomersCreate).toHaveBeenCalledOnce();
expect(mockCustomersCreate).toHaveBeenCalledWith({
email: "alice@example.com",
name: "Alice Smith",
metadata: { convexUserId: userId },
});
const updatedUser = await t.run(async (ctx) => ctx.db.get(userId));
expect(updatedUser?.stripeCustomerId).toBe("cus_fresh_456");
});
it("reuses existing Stripe customer and does not create a new one", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stripeCustomerId: "cus_existing_789",
});
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
expect(mockCustomersCreate).not.toHaveBeenCalled();
});
it("returns clientSecret on success", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
const result = await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
expect(result).toEqual({ clientSecret: "cs_test_secret_abc" });
});
it("builds correct line items from cart", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.line_items).toHaveLength(1);
expect(createCall.line_items[0]).toEqual({
price_data: {
currency: "gbp",
product_data: { name: "Premium Kibble — 1kg Bag" },
unit_amount: Math.round(24.99 * 100),
},
quantity: 2,
});
});
it("passes correct shipping options with amount in pence", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.shipping_options).toHaveLength(1);
const rateData = createCall.shipping_options[0].shipping_rate_data;
expect(rateData.fixed_amount.amount).toBe(550);
expect(rateData.fixed_amount.currency).toBe("gbp");
expect(rateData.display_name).toBe("DPD UK — Next Day");
expect(rateData.delivery_estimate.minimum).toEqual({
unit: "business_day",
value: 1,
});
});
it("passes correct metadata including all shipping fields", async () => {
const t = convexTest(schema, modules);
const { userId, addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.metadata).toEqual({
convexUserId: userId,
addressId,
shipmentObjectId: "shp_test_123",
shippingMethod: "DPD UK — Next Day",
shippingServiceCode: "dpd_uk_next_day",
carrier: "DPD UK",
carrierAccount: "ca_dpd_001",
});
});
it("creates session with ui_mode custom and correct return_url", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.mode).toBe("payment");
expect(createCall.ui_mode).toBe("custom");
expect(createCall.return_url).toBe(
"http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID}",
);
});
it("passes Stripe customer ID to session creation", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stripeCustomerId: "cus_existing_789",
});
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.customer).toBe("cus_existing_789");
});
it("throws when STOREFRONT_URL env var is missing", async () => {
vi.stubEnv("STOREFRONT_URL", "");
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/STOREFRONT_URL/i);
});
it("throws when Stripe returns no client_secret", async () => {
mockSessionsCreate.mockResolvedValue({
id: "cs_test_no_secret",
client_secret: null,
});
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/client_secret/i);
});
});
// ─── getCheckoutSessionStatus ────────────────────────────────────────────────
describe("stripeActions.getCheckoutSessionStatus", () => {
it("returns complete status with customer email", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "complete",
payment_status: "paid",
customer_details: { email: "alice@example.com" },
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_test_session_123",
});
expect(result).toEqual({
status: "complete",
paymentStatus: "paid",
customerEmail: "alice@example.com",
});
expect(mockSessionsRetrieve).toHaveBeenCalledWith("cs_test_session_123");
});
it("returns expired status", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "expired",
payment_status: "unpaid",
customer_details: null,
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_expired_456",
});
expect(result).toEqual({
status: "expired",
paymentStatus: "unpaid",
customerEmail: null,
});
});
it("returns open status when payment not completed", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "open",
payment_status: "unpaid",
customer_details: { email: "alice@example.com" },
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_open_789",
});
expect(result).toEqual({
status: "open",
paymentStatus: "unpaid",
customerEmail: "alice@example.com",
});
});
it("returns null email when customer_details is absent", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "complete",
payment_status: "paid",
customer_details: undefined,
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_no_email",
});
expect(result.customerEmail).toBeNull();
});
});