Files
the-pet-loft/convex/orders.test.ts
ianshaloom cc15338ad9 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>
2026-03-04 09:31:18 +03:00

565 lines
17 KiB
TypeScript

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