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>
565 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|