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

840
convex/products.test.ts Normal file
View File

@@ -0,0 +1,840 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_123",
});
const userId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(userId, { role: "admin" });
});
return asAdmin;
}
async function setupCategory(t: ReturnType<typeof convexTest>) {
let categoryId: any;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Pet Food",
slug: "pet-food",
});
});
return categoryId;
}
describe("products", () => {
it("list returns empty page when no products", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.list, {
paginationOpts: { numItems: 10, cursor: null },
});
expect(result.page).toHaveLength(0);
});
it("list returns only active products when filtered by status", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Active Product",
slug: "active-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Product",
slug: "draft-product",
status: "draft",
categoryId,
tags: [],
});
const result = await t.query(api.products.list, {
paginationOpts: { numItems: 10, cursor: null },
status: "active",
});
expect(result.page).toHaveLength(1);
expect(result.page[0].name).toBe("Active Product");
});
it("getBySlug returns product for valid slug", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Dog Treats",
slug: "dog-treats",
status: "active",
categoryId,
tags: ["dogs"],
});
const product = await t.query(api.products.getBySlug, {
slug: "dog-treats",
});
expect(product).not.toBeNull();
expect(product?.name).toBe("Dog Treats");
});
it("getBySlug returns null for non-existent slug", async () => {
const t = convexTest(schema, modules);
const product = await t.query(api.products.getBySlug, {
slug: "does-not-exist",
});
expect(product).toBeNull();
});
it("create throws for non-admin users", async () => {
const t = convexTest(schema, modules);
const categoryId = await setupCategory(t);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
await expect(
asCustomer.mutation(api.products.create, {
name: "Illegal Product",
slug: "illegal-product",
status: "active",
categoryId,
tags: [],
}),
).rejects.toThrow("Unauthorized: admin access required");
});
it("create succeeds for admin users", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Cat Toy",
slug: "cat-toy",
status: "draft",
categoryId,
tags: ["cats"],
});
expect(productId).toBeTruthy();
});
it("archive changes product status to archived", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Old Product",
slug: "old-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.archive, { id: productId });
const product = await t.query(api.products.getBySlug, {
slug: "old-product",
});
expect(product).toBeNull();
});
it("addVariant creates variant linked to product", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Variant Product",
slug: "variant-product",
status: "active",
categoryId,
tags: [],
});
const variantId = await asAdmin.mutation(api.products.addVariant, {
productId,
name: "Small",
sku: "VAR-SM-001",
price: 999,
stockQuantity: 10,
isActive: true,
});
expect(variantId).toBeTruthy();
const product = await t.query(api.products.getBySlug, {
slug: "variant-product",
});
expect(product).not.toBeNull();
expect(product?.variants).toHaveLength(1);
expect(product?.variants[0].name).toBe("Small");
expect(product?.variants[0].sku).toBe("VAR-SM-001");
expect(product?.variants[0].price).toBe(999);
});
it("updateVariant changes variant price", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Update Variant Product",
slug: "update-variant-product",
status: "active",
categoryId,
tags: [],
});
const variantId = await asAdmin.mutation(api.products.addVariant, {
productId,
name: "Medium",
sku: "VAR-MD-001",
price: 1999,
stockQuantity: 5,
isActive: true,
});
await asAdmin.mutation(api.products.updateVariant, {
id: variantId,
price: 1499,
});
const product = await t.query(api.products.getBySlug, {
slug: "update-variant-product",
});
expect(product?.variants[0].price).toBe(1499);
});
it("addImage inserts image with correct position", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Image Product",
slug: "image-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/first.jpg",
alt: "First image",
position: 1,
});
await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/second.jpg",
alt: "Second image",
position: 0,
});
const product = await t.query(api.products.getBySlug, {
slug: "image-product",
});
expect(product?.images).toHaveLength(2);
expect(product?.images[0].position).toBe(0);
expect(product?.images[0].url).toBe("https://example.com/second.jpg");
expect(product?.images[1].position).toBe(1);
expect(product?.images[1].url).toBe("https://example.com/first.jpg");
});
it("reorderImages updates position values", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Reorder Product",
slug: "reorder-product",
status: "active",
categoryId,
tags: [],
});
const id1 = await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/a.jpg",
position: 0,
});
const id2 = await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/b.jpg",
position: 1,
});
await asAdmin.mutation(api.products.reorderImages, {
updates: [
{ id: id1, position: 1 },
{ id: id2, position: 0 },
],
});
const product = await t.query(api.products.getBySlug, {
slug: "reorder-product",
});
expect(product?.images).toHaveLength(2);
expect(product?.images[0].position).toBe(0);
expect(product?.images[0].url).toBe("https://example.com/b.jpg");
expect(product?.images[1].position).toBe(1);
expect(product?.images[1].url).toBe("https://example.com/a.jpg");
});
it("search returns products matching query string", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Dog Treats",
slug: "dog-treats",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Cat Food",
slug: "cat-food",
status: "active",
categoryId,
tags: [],
});
const result = await t.query(api.products.search, { query: "Dog" });
expect(result).toHaveLength(1);
expect(result[0].name).toContain("Dog");
});
it("search respects status filter", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Active Treats",
slug: "active-treats",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Treats",
slug: "draft-treats",
status: "draft",
categoryId,
tags: [],
});
const result = await t.query(api.products.search, {
query: "Treats",
status: "active",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Active Treats");
});
it("search with no matches returns empty array", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.search, {
query: "xyznonexistent",
});
expect(result).toEqual([]);
});
describe("searchTypeahead", () => {
async function setupHierarchicalCategories(t: ReturnType<typeof convexTest>) {
let dogsChildCategoryId: any;
let catsChildCategoryId: any;
await t.run(async (ctx) => {
const dogsId = await ctx.db.insert("categories", {
name: "Dogs",
slug: "dogs",
});
dogsChildCategoryId = await ctx.db.insert("categories", {
name: "Dog Food",
slug: "dog-food",
parentId: dogsId,
});
const catsId = await ctx.db.insert("categories", {
name: "Cats",
slug: "cats",
});
catsChildCategoryId = await ctx.db.insert("categories", {
name: "Cat Food",
slug: "cat-food",
parentId: catsId,
});
});
return { dogsChildCategoryId, catsChildCategoryId };
}
it("returns empty array for query shorter than 3 characters", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.searchTypeahead, { query: "do" });
expect(result).toEqual([]);
});
it("returns empty array for query of exactly 2 characters", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.searchTypeahead, { query: "ab" });
expect(result).toEqual([]);
});
it("returns empty array when no products match the query", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.searchTypeahead, {
query: "xyznonexistent",
});
expect(result).toEqual([]);
});
it("returns active products matching the query", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Premium Dog Kibble",
slug: "premium-dog-kibble",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Cat Litter Deluxe",
slug: "cat-litter-deluxe",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Kibble",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Premium Dog Kibble");
});
it("does not return draft or archived products", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Active Bone Treat",
slug: "active-bone-treat",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Bone Treat",
slug: "draft-bone-treat",
status: "draft",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Bone",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Active Bone Treat");
});
it("filters by parentCategorySlug when provided", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId, catsChildCategoryId } =
await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Royal Canin Dog Food",
slug: "royal-canin-dog-food",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Royal Canin Cat Food",
slug: "royal-canin-cat-food",
status: "active",
categoryId: catsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Royal Canin",
parentCategorySlug: "cats",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Royal Canin Cat Food");
expect(result[0].parentCategorySlug).toBe("cats");
});
it("returns all matching products when no category filter is provided", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId, catsChildCategoryId } =
await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Pro Plan Dog Food",
slug: "pro-plan-dog-food",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Pro Plan Cat Food",
slug: "pro-plan-cat-food",
status: "active",
categoryId: catsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Pro Plan",
});
expect(result).toHaveLength(2);
});
it("returns enriched data: imageUrl, minPrice, slug, and category slugs", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Enriched Dog Treat",
slug: "enriched-dog-treat",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/treat.jpg",
alt: "Dog Treat",
position: 0,
});
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "100g",
sku: "TREAT-100G",
price: 599,
stockQuantity: 50,
isActive: true,
});
const result = await t.query(api.products.searchTypeahead, {
query: "Enriched",
});
expect(result).toHaveLength(1);
const item = result[0];
expect(item.slug).toBe("enriched-dog-treat");
expect(item.parentCategorySlug).toBe("dogs");
expect(item.childCategorySlug).toBe("dog-food");
expect(item.imageUrl).toBe("https://example.com/treat.jpg");
expect(item.imageAlt).toBe("Dog Treat");
expect(item.minPrice).toBe(599);
});
it("returns minPrice=0 when product has no active variants", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Variantless Collar",
slug: "variantless-collar",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Variantless",
});
expect(result).toHaveLength(1);
expect(result[0].minPrice).toBe(0);
});
it("returns imageUrl as undefined when product has no images", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Imageless Dog Toy",
slug: "imageless-dog-toy",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Imageless",
});
expect(result).toHaveLength(1);
expect(result[0].imageUrl).toBeUndefined();
});
it("picks the lowest priced active variant as minPrice", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Multi-Variant Dog Food",
slug: "multi-variant-dog-food",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "1kg",
sku: "MV-1KG",
price: 1200,
stockQuantity: 10,
isActive: true,
});
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "500g",
sku: "MV-500G",
price: 799,
stockQuantity: 20,
isActive: true,
});
// Inactive variant with a lower price — should be ignored
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "2kg",
sku: "MV-2KG",
price: 199,
stockQuantity: 5,
isActive: false,
});
const result = await t.query(api.products.searchTypeahead, {
query: "Multi-Variant",
});
expect(result).toHaveLength(1);
expect(result[0].minPrice).toBe(799);
});
it("respects the limit parameter", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
for (let i = 0; i < 5; i++) {
await asAdmin.mutation(api.products.create, {
name: `Dog Snack ${i}`,
slug: `dog-snack-${i}`,
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
}
const result = await t.query(api.products.searchTypeahead, {
query: "Dog Snack",
limit: 3,
});
expect(result).toHaveLength(3);
});
});
describe("listByTag", () => {
it("returns only active products with the given tag", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Sale Product",
slug: "sale-product",
status: "active",
categoryId,
tags: ["sale", "dogs"],
});
await asAdmin.mutation(api.products.create, {
name: "Top Pick",
slug: "top-pick",
status: "active",
categoryId,
tags: ["top-picks"],
});
await asAdmin.mutation(api.products.create, {
name: "Regular Product",
slug: "regular-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Sale",
slug: "draft-sale",
status: "draft",
categoryId,
tags: ["sale"],
});
const result = await t.query(api.products.listByTag, { tag: "sale" });
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Sale Product");
});
it("returns empty array when no products match the tag", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Regular Product",
slug: "regular-product",
status: "active",
categoryId,
tags: ["dogs"],
});
const result = await t.query(api.products.listByTag, { tag: "sale" });
expect(result).toHaveLength(0);
});
it("respects the limit arg", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
for (let i = 0; i < 5; i++) {
await asAdmin.mutation(api.products.create, {
name: `Sale Product ${i}`,
slug: `sale-product-${i}`,
status: "active",
categoryId,
tags: ["sale"],
});
}
const result = await t.query(api.products.listByTag, {
tag: "sale",
limit: 3,
});
expect(result).toHaveLength(3);
});
it("returns products matching top-picks tag", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Top Pick A",
slug: "top-pick-a",
status: "active",
categoryId,
tags: ["top-picks"],
});
await asAdmin.mutation(api.products.create, {
name: "Top Pick B",
slug: "top-pick-b",
status: "active",
categoryId,
tags: ["top-picks", "sale"],
});
await asAdmin.mutation(api.products.create, {
name: "No Pick",
slug: "no-pick",
status: "active",
categoryId,
tags: ["sale"],
});
const result = await t.query(api.products.listByTag, { tag: "top-picks" });
expect(result).toHaveLength(2);
const names = result.map((p: any) => p.name).sort();
expect(names).toEqual(["Top Pick A", "Top Pick B"]);
});
});
describe("listRecentlyAdded", () => {
it("returns active products created within the last 30 days", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
// These will have _creationTime = now (within 30 days)
await asAdmin.mutation(api.products.create, {
name: "New Product A",
slug: "new-product-a",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "New Product B",
slug: "new-product-b",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft New",
slug: "draft-new",
status: "draft",
categoryId,
tags: [],
});
const result = await t.query(api.products.listRecentlyAdded, {});
// Only the two active products should appear; draft is excluded
expect(result.length).toBeGreaterThanOrEqual(2);
const names = result.map((p: any) => p.name);
expect(names).toContain("New Product A");
expect(names).toContain("New Product B");
expect(names).not.toContain("Draft New");
});
it("respects the limit arg", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
for (let i = 0; i < 5; i++) {
await asAdmin.mutation(api.products.create, {
name: `Product ${i}`,
slug: `product-${i}`,
status: "active",
categoryId,
tags: [],
});
}
const result = await t.query(api.products.listRecentlyAdded, { limit: 3 });
expect(result).toHaveLength(3);
});
it("returns empty array when no active products exist", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.listRecentlyAdded, {});
expect(result).toHaveLength(0);
});
});
});