import { convexTest } from "convex-test"; import { describe, it, expect, beforeEach } from "vitest"; import { api } from "./_generated/api"; import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); async function setupUserAndData(t: ReturnType) { const asCustomer = t.withIdentity({ name: "Alice", email: "alice@example.com", subject: "clerk_alice_123", }); const userId = await asCustomer.mutation(api.users.store, {}); let categoryId: any; let variantId: any; await t.run(async (ctx) => { categoryId = await ctx.db.insert("categories", { name: "Toys", slug: "toys", }); const productId = await ctx.db.insert("products", { name: "Ball", slug: "ball", status: "active", categoryId, tags: [], parentCategorySlug: "toys", childCategorySlug: "toys", }); variantId = await ctx.db.insert("productVariants", { productId, name: "Red Ball", sku: "BALL-RED-001", price: 999, stockQuantity: 50, attributes: { color: "Red" }, isActive: true, weight: 200, weightUnit: "g", }); }); return { asCustomer, variantId, userId }; } async function setupAdminUser(t: ReturnType) { const asAdmin = t.withIdentity({ name: "Admin", email: "admin@example.com", subject: "clerk_admin_456", }); const userId = await asAdmin.mutation(api.users.store, {}); await t.run(async (ctx) => { await ctx.db.patch(userId, { role: "admin" }); }); return asAdmin; } describe("orders", () => { it("listMine returns only the current user's orders", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); await makeConfirmedOrder(t, userId, variantId); const result = await asCustomer.query(api.orders.listMine, { paginationOpts: { numItems: 10, cursor: null }, }); expect(result.page).toHaveLength(1); }); it("listMine returns empty for user with no orders", async () => { const t = convexTest(schema, modules); const asCustomer = t.withIdentity({ name: "Bob", email: "bob@example.com", subject: "clerk_bob_123", }); await asCustomer.mutation(api.users.store, {}); const result = await asCustomer.query(api.orders.listMine, { paginationOpts: { numItems: 10, cursor: null }, }); expect(result.page).toHaveLength(0); }); it("getById throws if order belongs to a different user", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); const asBob = t.withIdentity({ name: "Bob", email: "bob@example.com", subject: "clerk_bob_123", }); await asBob.mutation(api.users.store, {}); await expect( asBob.query(api.orders.getById, { id: orderId }), ).rejects.toThrow("Unauthorized: order does not belong to you"); }); it("create creates order and order items atomically", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await asCustomer.mutation(api.orders.create, { shippingAddressSnapshot: { fullName: "Alice Customer", firstName: "Alice", lastName: "Customer", addressLine1: "123 Main St", city: "Springfield", postalCode: "62701", country: "US", }, billingAddressSnapshot: { firstName: "Alice", lastName: "Customer", addressLine1: "123 Main St", city: "Springfield", postalCode: "62701", country: "US", }, items: [ { variantId, productName: "Ball", variantName: "Red Ball", sku: "BALL-RED-001", quantity: 3, unitPrice: 999, }, ], shippingCost: 500, discount: 100, }); const order = await asCustomer.query(api.orders.getById, { id: orderId }); expect(order).not.toBeNull(); expect(order.subtotal).toBe(2997); expect(order.total).toBe(2997 + 500 - 100 + 0); // subtotal + shipping - discount + tax expect(order.items).toHaveLength(1); expect(order.items[0].totalPrice).toBe(2997); }); it("updateStatus throws for non-admin users", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); await expect( asCustomer.mutation(api.orders.updateStatus, { id: orderId, status: "shipped", }), ).rejects.toThrow("Unauthorized: admin access required"); }); it("createFromCart creates order and clears cart on success", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 2 }); const orderId = await asCustomer.mutation(api.orders.createFromCart, { shippingAddressSnapshot: { fullName: "Alice Customer", firstName: "Alice", lastName: "Customer", addressLine1: "123 Main St", city: "Springfield", postalCode: "62701", country: "US", }, billingAddressSnapshot: { firstName: "Alice", lastName: "Customer", addressLine1: "123 Main St", city: "Springfield", postalCode: "62701", country: "US", }, shippingCost: 500, discount: 0, }); expect(orderId).toBeTruthy(); const order = await asCustomer.query(api.orders.getById, { id: orderId }); expect(order.items).toHaveLength(1); expect(order.items[0].quantity).toBe(2); const cart = await asCustomer.query(api.carts.get, {}); expect(cart!.items).toHaveLength(0); }); it("createFromCart throws when item is out of stock; cart unchanged", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 10 }); await t.run(async (ctx) => { await ctx.db.patch(variantId, { stockQuantity: 2 }); }); await expect( asCustomer.mutation(api.orders.createFromCart, { shippingAddressSnapshot: { fullName: "Alice", firstName: "Alice", lastName: "Customer", addressLine1: "123 Main St", city: "Springfield", postalCode: "62701", country: "US", }, billingAddressSnapshot: { firstName: "Alice", lastName: "Customer", addressLine1: "123 Main St", city: "Springfield", postalCode: "62701", country: "US", }, shippingCost: 0, discount: 0, }), ).rejects.toThrow("One or more items are out of stock"); const cart = await asCustomer.query(api.carts.get, {}); expect(cart!.items).toHaveLength(1); expect(cart!.items[0].quantity).toBe(10); }); }); // ─── Helper reused across cancel + statusFilter tests ──────────────────────── /** * Inserts a "confirmed" order + its items directly into the DB. * Uses t.run() to bypass the create mutation validator so we can * set all required schema fields precisely for cancel/statusFilter tests. */ async function makeConfirmedOrder( t: ReturnType, userId: any, variantId: any, quantity = 2, ) { let orderId: any; await t.run(async (ctx) => { orderId = await ctx.db.insert("orders", { orderNumber: `ORD-TEST-${Math.random().toString(36).slice(2, 7).toUpperCase()}`, userId, email: "alice@example.com", status: "confirmed", paymentStatus: "paid", subtotal: 999 * quantity, tax: 0, shipping: 500, discount: 0, total: 999 * quantity + 500, currency: "GBP", shippingAddressSnapshot: { fullName: "Alice Smith", firstName: "Alice", lastName: "Smith", addressLine1: "1 Test Lane", city: "London", postalCode: "E1 1AA", country: "GB", }, billingAddressSnapshot: { firstName: "Alice", lastName: "Smith", addressLine1: "1 Test Lane", city: "London", postalCode: "E1 1AA", country: "GB", }, shippoShipmentId: "shp_test", shippingMethod: "Standard", shippingServiceCode: "std", carrier: "DPD", createdAt: Date.now(), updatedAt: Date.now(), }); await ctx.db.insert("orderItems", { orderId, variantId, productName: "Ball", variantName: "Red Ball", sku: "BALL-RED-001", quantity, unitPrice: 999, totalPrice: 999 * quantity, }); }); return orderId; } describe("orders.cancel", () => { it("cancels a confirmed order and sets status to cancelled", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); const result = await asCustomer.mutation(api.orders.cancel, { id: orderId }); expect(result).toEqual({ success: true }); const order = await asCustomer.query(api.orders.getById, { id: orderId }); expect(order.status).toBe("cancelled"); }); it("restores variant stock after cancellation", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); let stockBefore: number | undefined; await t.run(async (ctx) => { const v = await ctx.db.get(variantId); stockBefore = v?.stockQuantity; }); const orderId = await makeConfirmedOrder(t, userId, variantId, 3); await asCustomer.mutation(api.orders.cancel, { id: orderId }); await t.run(async (ctx) => { const v = await ctx.db.get(variantId); expect(v?.stockQuantity).toBe(stockBefore! + 3); }); }); it("throws when order is in processing status", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); await t.run(async (ctx) => { await ctx.db.patch(orderId, { status: "processing" }); }); await expect( asCustomer.mutation(api.orders.cancel, { id: orderId }), ).rejects.toThrow("Order has progressed past the cancellation window"); }); it("throws when order is in pending status", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); await t.run(async (ctx) => { await ctx.db.patch(orderId, { status: "pending" }); }); await expect( asCustomer.mutation(api.orders.cancel, { id: orderId }), ).rejects.toThrow("Order is still awaiting payment confirmation."); }); it("throws when trying to cancel an already cancelled order", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); await asCustomer.mutation(api.orders.cancel, { id: orderId }); await expect( asCustomer.mutation(api.orders.cancel, { id: orderId }), ).rejects.toThrow("Order is already cancelled."); }); it("throws when cancelling another user's order", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); const asBob = t.withIdentity({ name: "Bob", email: "bob@example.com", subject: "clerk_bob_cancel", }); await asBob.mutation(api.users.store, {}); await expect( asBob.mutation(api.orders.cancel, { id: orderId }), ).rejects.toThrow("Unauthorized: order does not belong to you"); }); it("throws when order id does not exist", async () => { const t = convexTest(schema, modules); const asCustomer = t.withIdentity({ name: "Alice", email: "alice@example.com", subject: "clerk_alice_ghost", }); await asCustomer.mutation(api.users.store, {}); // Use a valid-looking but non-existent order id by creating+deleting an order const { variantId, userId } = await setupUserAndData(t); const orderId = await makeConfirmedOrder(t, userId, variantId); await t.run(async (ctx) => { await ctx.db.delete(orderId); }); await expect( asCustomer.mutation(api.orders.cancel, { id: orderId }), ).rejects.toThrow("Order not found"); }); }); describe("orders.listMine with statusFilter", () => { it("returns all orders when no statusFilter is provided", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); await makeConfirmedOrder(t, userId, variantId); // Create a second order in default "pending" status via t.run await t.run(async (ctx) => { await ctx.db.insert("orders", { orderNumber: `ORD-PENDING-${Date.now()}`, userId, email: "alice@example.com", status: "pending", paymentStatus: "pending", subtotal: 999, tax: 0, shipping: 0, discount: 0, total: 999, currency: "GBP", shippingAddressSnapshot: { fullName: "Alice Smith", firstName: "Alice", lastName: "Smith", addressLine1: "1 Test Lane", city: "London", postalCode: "E1 1AA", country: "GB", }, billingAddressSnapshot: { firstName: "Alice", lastName: "Smith", addressLine1: "1 Test Lane", city: "London", postalCode: "E1 1AA", country: "GB", }, shippoShipmentId: "", shippingMethod: "", shippingServiceCode: "", carrier: "", createdAt: Date.now(), updatedAt: Date.now(), }); }); const result = await asCustomer.query(api.orders.listMine, { paginationOpts: { numItems: 10, cursor: null }, }); expect(result.page).toHaveLength(2); }); it("filters to a single status when statusFilter contains one value", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); // "confirmed" order await makeConfirmedOrder(t, userId, variantId); // "pending" order via t.run await t.run(async (ctx) => { await ctx.db.insert("orders", { orderNumber: `ORD-PENDING-${Date.now()}`, userId, email: "alice@example.com", status: "pending", paymentStatus: "pending", subtotal: 999, tax: 0, shipping: 0, discount: 0, total: 999, currency: "GBP", shippingAddressSnapshot: { fullName: "Alice Smith", firstName: "Alice", lastName: "Smith", addressLine1: "1 Test Lane", city: "London", postalCode: "E1 1AA", country: "GB", }, billingAddressSnapshot: { firstName: "Alice", lastName: "Smith", addressLine1: "1 Test Lane", city: "London", postalCode: "E1 1AA", country: "GB", }, shippoShipmentId: "", shippingMethod: "", shippingServiceCode: "", carrier: "", createdAt: Date.now(), updatedAt: Date.now(), }); }); const result = await asCustomer.query(api.orders.listMine, { paginationOpts: { numItems: 10, cursor: null }, statusFilter: ["confirmed"], }); expect(result.page).toHaveLength(1); expect(result.page[0].status).toBe("confirmed"); }); it("filters to multiple statuses when statusFilter contains several values", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); // confirmed order await makeConfirmedOrder(t, userId, variantId); // processing order const orderId2 = await makeConfirmedOrder(t, userId, variantId); await t.run(async (ctx) => { await ctx.db.patch(orderId2, { status: "processing" }); }); // delivered order const orderId3 = await makeConfirmedOrder(t, userId, variantId); await t.run(async (ctx) => { await ctx.db.patch(orderId3, { status: "delivered" }); }); const result = await asCustomer.query(api.orders.listMine, { paginationOpts: { numItems: 10, cursor: null }, statusFilter: ["confirmed", "processing"], }); expect(result.page).toHaveLength(2); expect(result.page.map((o: any) => o.status).sort()).toEqual([ "confirmed", "processing", ]); }); it("returns empty page when no orders match the statusFilter", async () => { const t = convexTest(schema, modules); const { asCustomer, variantId, userId } = await setupUserAndData(t); await makeConfirmedOrder(t, userId, variantId); const result = await asCustomer.query(api.orders.listMine, { paginationOpts: { numItems: 10, cursor: null }, statusFilter: ["delivered"], }); expect(result.page).toHaveLength(0); }); });