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:
447
convex/checkout.test.ts
Normal file
447
convex/checkout.test.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { api, internal } 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",
|
||||
};
|
||||
|
||||
describe("checkout.getShippingAddresses", () => {
|
||||
it("returns only shipping addresses (excludes billing)", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
isDefault: true,
|
||||
});
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "billing",
|
||||
addressLine1: "1 Treasury Place",
|
||||
isDefault: false,
|
||||
});
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
addressLine1: "221B Baker Street",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((a) => a.type === "shipping")).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts default address first, then by recency descending", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
addressLine1: "First added",
|
||||
isDefault: false,
|
||||
});
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
addressLine1: "Second added (default)",
|
||||
isDefault: true,
|
||||
});
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
addressLine1: "Third added",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].addressLine1).toBe("Second added (default)");
|
||||
expect(result[0].isDefault).toBe(true);
|
||||
expect(result[1].addressLine1).toBe("Third added");
|
||||
expect(result[2].addressLine1).toBe("First added");
|
||||
});
|
||||
|
||||
it("returns empty array when user has no shipping addresses", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when user only has billing addresses", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "billing",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes isValidated field on returned addresses", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
isDefault: true,
|
||||
isValidated: true,
|
||||
});
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
addressLine1: "221B Baker Street",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].isValidated).toBe(true);
|
||||
expect(result[1].isValidated).toBe(false);
|
||||
});
|
||||
|
||||
it("includes additionalInformation when present", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
additionalInformation: "Flat 4B",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].additionalInformation).toBe("Flat 4B");
|
||||
});
|
||||
|
||||
it("returned addresses have no state or addressLine2 fields", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
await asA.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const result = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
const addr = result[0] as Record<string, unknown>;
|
||||
expect(addr).not.toHaveProperty("state");
|
||||
expect(addr).not.toHaveProperty("addressLine2");
|
||||
});
|
||||
|
||||
it("throws for unauthenticated users", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
await expect(
|
||||
t.query(api.checkout.getShippingAddresses, {}),
|
||||
).rejects.toThrow(/Unauthenticated/i);
|
||||
});
|
||||
|
||||
it("does not return addresses belonging to other users", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
const asB = t.withIdentity({
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
subject: "clerk_bob_456",
|
||||
});
|
||||
await asA.mutation(api.users.store, {});
|
||||
await asB.mutation(api.users.store, {});
|
||||
|
||||
await asA.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
isDefault: true,
|
||||
});
|
||||
await asB.mutation(api.addresses.add, {
|
||||
...baseAddress,
|
||||
type: "shipping",
|
||||
addressLine1: "Bob's address",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const resultA = await asA.query(api.checkout.getShippingAddresses, {});
|
||||
expect(resultA).toHaveLength(1);
|
||||
expect(resultA[0].addressLine1).toBe("10 Downing Street");
|
||||
|
||||
const resultB = await asB.query(api.checkout.getShippingAddresses, {});
|
||||
expect(resultB).toHaveLength(1);
|
||||
expect(resultB[0].addressLine1).toBe("Bob's address");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAddressById (internal query) ─────────────────────────────────────────
|
||||
|
||||
describe("checkout.getAddressById", () => {
|
||||
it("returns the address document when it exists", 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,
|
||||
});
|
||||
|
||||
const result = await t.query(internal.checkout.getAddressById, {
|
||||
addressId,
|
||||
});
|
||||
expect(result.addressLine1).toBe("10 Downing Street");
|
||||
expect(result.fullName).toBe("Alice Smith");
|
||||
expect(result.city).toBe("London");
|
||||
expect(result.postalCode).toBe("SW1A 2AA");
|
||||
expect(result.country).toBe("GB");
|
||||
});
|
||||
|
||||
it("throws when the address does not exist", 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 asA.mutation(api.addresses.remove, { id: addressId });
|
||||
|
||||
await expect(
|
||||
t.query(internal.checkout.getAddressById, { addressId }),
|
||||
).rejects.toThrow(/Address not found/i);
|
||||
});
|
||||
|
||||
it("returns additionalInformation when present on address", 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",
|
||||
additionalInformation: "Flat 4B",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const result = await t.query(internal.checkout.getAddressById, {
|
||||
addressId,
|
||||
});
|
||||
expect(result.additionalInformation).toBe("Flat 4B");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCurrentUserId (internal query) ───────────────────────────────────────
|
||||
|
||||
describe("checkout.getCurrentUserId", () => {
|
||||
it("returns the user ID for an authenticated user", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
const userId = await asA.mutation(api.users.store, {});
|
||||
|
||||
const result = await asA.query(internal.checkout.getCurrentUserId);
|
||||
expect(result).toBe(userId);
|
||||
});
|
||||
|
||||
it("throws for unauthenticated requests", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
await expect(
|
||||
t.query(internal.checkout.getCurrentUserId),
|
||||
).rejects.toThrow(/Unauthenticated/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateCartInternal (internal query) ───────────────────────────────────
|
||||
|
||||
describe("checkout.validateCartInternal", () => {
|
||||
async function setupProductAndVariant(
|
||||
t: ReturnType<typeof convexTest>,
|
||||
overrides?: { stockQuantity?: number; price?: number; isActive?: boolean },
|
||||
) {
|
||||
let productId: Id<"products">;
|
||||
let variantId: Id<"productVariants">;
|
||||
let categoryId: Id<"categories">;
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
return { productId: productId!, variantId: variantId!, categoryId: categoryId! };
|
||||
}
|
||||
|
||||
it("returns null when the user has no cart", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
const userId = await asA.mutation(api.users.store, {});
|
||||
|
||||
const result = await t.query(internal.checkout.validateCartInternal, {
|
||||
userId,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the cart has no items", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
const userId = await asA.mutation(api.users.store, {});
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("carts", {
|
||||
userId,
|
||||
items: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400000,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await t.query(internal.checkout.validateCartInternal, {
|
||||
userId,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a valid cart validation result with enriched items", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
const userId = await asA.mutation(api.users.store, {});
|
||||
const { productId, variantId } = await setupProductAndVariant(t);
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("carts", {
|
||||
userId,
|
||||
items: [{ productId, variantId, quantity: 2, price: 2499 }],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400000,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await t.query(internal.checkout.validateCartInternal, {
|
||||
userId,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
expect(result!.items).toHaveLength(1);
|
||||
expect(result!.items[0].productName).toBe("Premium Kibble");
|
||||
expect(result!.items[0].variantName).toBe("1kg Bag");
|
||||
expect(result!.items[0].quantity).toBe(2);
|
||||
expect(result!.items[0].weight).toBe(1000);
|
||||
expect(result!.items[0].weightUnit).toBe("g");
|
||||
expect(result!.subtotal).toBe(2499 * 2);
|
||||
});
|
||||
|
||||
it("returns issues for out-of-stock items", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asA = t.withIdentity(identity);
|
||||
const userId = await asA.mutation(api.users.store, {});
|
||||
const { productId, variantId } = await setupProductAndVariant(t, {
|
||||
stockQuantity: 0,
|
||||
});
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("carts", {
|
||||
userId,
|
||||
items: [{ productId, variantId, quantity: 1, price: 2499 }],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400000,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await t.query(internal.checkout.validateCartInternal, {
|
||||
userId,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(false);
|
||||
expect(result!.issues).toHaveLength(1);
|
||||
expect(result!.issues[0].type).toBe("out_of_stock");
|
||||
});
|
||||
|
||||
it("resolves cart by sessionId when userId not provided", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { productId, variantId } = await setupProductAndVariant(t);
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("carts", {
|
||||
sessionId: "sess_abc123",
|
||||
items: [{ productId, variantId, quantity: 1, price: 2499 }],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400000,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await t.query(internal.checkout.validateCartInternal, {
|
||||
sessionId: "sess_abc123",
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
expect(result!.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user