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) { 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) { 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) { 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); }); }); });