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:
564
convex/orders.test.ts
Normal file
564
convex/orders.test.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user