feat(convex): add implementation rules and configuration for backend functions
- Introduced a comprehensive markdown document outlining implementation rules for Convex functions, including syntax, registration, HTTP endpoints, and TypeScript usage. - Created a new configuration file for the Convex app, integrating the Resend service. - Added a new HTTP route for handling Shippo webhooks to ensure proper response handling. - Implemented integration tests for order timeline events, covering various scenarios including order fulfillment and status changes. - Enhanced existing functions with type safety improvements and additional validation logic. This commit establishes clear guidelines for backend development and improves the overall structure and reliability of the Convex application.
This commit is contained in:
576
convex/orders.timeline.test.ts
Normal file
576
convex/orders.timeline.test.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Phase 1 — orderTimelineEvents integration tests
|
||||
*
|
||||
* Covers:
|
||||
* - recordOrderTimelineEvent (helper, via t.run)
|
||||
* - fulfillFromCheckout → timeline event created (source: stripe_webhook)
|
||||
* - fulfillFromCheckout → idempotent: no duplicate event on replay
|
||||
* - orders.updateStatus → timeline event with fromStatus/toStatus/userId
|
||||
* - orders.cancel → timeline event customer_cancel
|
||||
* - orders.getTimeline → ordering, auth enforcement
|
||||
*/
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import schema from "./schema";
|
||||
import { recordOrderTimelineEvent } from "./model/orders";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function setupCustomerAndProduct(t: ReturnType<typeof convexTest>) {
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
subject: "clerk_alice_tl",
|
||||
});
|
||||
const userId = await asCustomer.mutation(api.users.store, {});
|
||||
|
||||
let variantId: any;
|
||||
await t.run(async (ctx) => {
|
||||
const categoryId = await ctx.db.insert("categories", {
|
||||
name: "Toys",
|
||||
slug: "toys-tl",
|
||||
});
|
||||
const productId = await ctx.db.insert("products", {
|
||||
name: "Ball",
|
||||
slug: "ball-tl",
|
||||
status: "active",
|
||||
categoryId,
|
||||
tags: [],
|
||||
parentCategorySlug: "toys-tl",
|
||||
childCategorySlug: "toys-tl",
|
||||
});
|
||||
variantId = await ctx.db.insert("productVariants", {
|
||||
productId,
|
||||
name: "Red Ball",
|
||||
sku: "BALL-TL-001",
|
||||
price: 999,
|
||||
stockQuantity: 50,
|
||||
isActive: true,
|
||||
weight: 200,
|
||||
weightUnit: "g",
|
||||
});
|
||||
});
|
||||
|
||||
return { asCustomer, userId, variantId };
|
||||
}
|
||||
|
||||
async function setupAdmin(t: ReturnType<typeof convexTest>) {
|
||||
const asAdmin = t.withIdentity({
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
subject: "clerk_admin_tl",
|
||||
});
|
||||
const adminId = await asAdmin.mutation(api.users.store, {});
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.patch(adminId, { role: "admin" });
|
||||
});
|
||||
return { asAdmin, adminId };
|
||||
}
|
||||
|
||||
/** Insert a confirmed order + one order item directly into the DB. */
|
||||
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-TL-${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-TL-001",
|
||||
quantity,
|
||||
unitPrice: 999,
|
||||
totalPrice: 999 * quantity,
|
||||
});
|
||||
});
|
||||
return orderId;
|
||||
}
|
||||
|
||||
/** Full setup required to invoke fulfillFromCheckout (user, address, cart item). */
|
||||
async function setupFulfillmentData(t: ReturnType<typeof convexTest>) {
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
|
||||
let addressId: any;
|
||||
await t.run(async (ctx) => {
|
||||
addressId = await ctx.db.insert("addresses", {
|
||||
userId,
|
||||
type: "shipping",
|
||||
fullName: "Alice Smith",
|
||||
firstName: "Alice",
|
||||
lastName: "Smith",
|
||||
phone: "+447911123456",
|
||||
addressLine1: "1 Test Lane",
|
||||
city: "London",
|
||||
postalCode: "E1 1AA",
|
||||
country: "GB",
|
||||
isDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 2 });
|
||||
|
||||
return { asCustomer, userId, variantId, addressId };
|
||||
}
|
||||
|
||||
// ─── recordOrderTimelineEvent helper ─────────────────────────────────────────
|
||||
|
||||
describe("recordOrderTimelineEvent", () => {
|
||||
it("inserts a timeline event with all provided fields", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await recordOrderTimelineEvent(ctx as unknown as MutationCtx, {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "admin",
|
||||
fromStatus: "confirmed",
|
||||
toStatus: "shipped",
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventType).toBe("status_change");
|
||||
expect(events[0].source).toBe("admin");
|
||||
expect(events[0].fromStatus).toBe("confirmed");
|
||||
expect(events[0].toStatus).toBe("shipped");
|
||||
expect(events[0].userId).toBe(userId);
|
||||
});
|
||||
|
||||
it("sets createdAt automatically", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
const before = Date.now();
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await recordOrderTimelineEvent(ctx as unknown as MutationCtx, {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "admin",
|
||||
toStatus: "confirmed",
|
||||
});
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events[0].createdAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("stores optional payload as a JSON string", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
const payload = JSON.stringify({ amount: 1999, currency: "gbp" });
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await recordOrderTimelineEvent(ctx as unknown as MutationCtx, {
|
||||
orderId,
|
||||
eventType: "refund",
|
||||
source: "admin",
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events[0].payload).toBe(payload);
|
||||
expect(JSON.parse(events[0].payload!).amount).toBe(1999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fulfillFromCheckout → timeline ──────────────────────────────────────────
|
||||
|
||||
describe("orders.fulfillFromCheckout - timeline events", () => {
|
||||
it("records a status_change event with toStatus: confirmed after order creation", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, addressId } = await setupFulfillmentData(t);
|
||||
|
||||
const orderId = await t.mutation(internal.orders.fulfillFromCheckout, {
|
||||
stripeCheckoutSessionId: "cs_test_tl_001",
|
||||
stripePaymentIntentId: "pi_test_tl_001",
|
||||
convexUserId: userId,
|
||||
addressId,
|
||||
shipmentObjectId: "shp_tl_001",
|
||||
shippingMethod: "Standard",
|
||||
shippingServiceCode: "std",
|
||||
carrier: "DPD",
|
||||
amountTotal: 2498,
|
||||
amountShipping: 500,
|
||||
currency: "gbp",
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventType).toBe("status_change");
|
||||
expect(events[0].source).toBe("stripe_webhook");
|
||||
expect(events[0].toStatus).toBe("confirmed");
|
||||
expect(events[0].fromStatus).toBeUndefined();
|
||||
expect(events[0].userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not record a duplicate event on replay (idempotent)", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, addressId } = await setupFulfillmentData(t);
|
||||
|
||||
const fulfillArgs = {
|
||||
stripeCheckoutSessionId: "cs_test_tl_idem",
|
||||
stripePaymentIntentId: null,
|
||||
convexUserId: userId,
|
||||
addressId,
|
||||
shipmentObjectId: "shp_tl_idem",
|
||||
shippingMethod: "Standard",
|
||||
shippingServiceCode: "std",
|
||||
carrier: "DPD",
|
||||
amountTotal: null,
|
||||
amountShipping: 500,
|
||||
currency: null,
|
||||
};
|
||||
|
||||
const orderId = await t.mutation(
|
||||
internal.orders.fulfillFromCheckout,
|
||||
fulfillArgs,
|
||||
);
|
||||
// Replay — order already exists; should return same id without inserting a new event
|
||||
const orderId2 = await t.mutation(
|
||||
internal.orders.fulfillFromCheckout,
|
||||
fulfillArgs,
|
||||
);
|
||||
|
||||
expect(orderId2).toBe(orderId);
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── orders.updateStatus → timeline ──────────────────────────────────────────
|
||||
|
||||
describe("orders.updateStatus - timeline events", () => {
|
||||
it("records a status_change event with correct fromStatus and toStatus", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const { asAdmin } = await setupAdmin(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await asAdmin.mutation(api.orders.updateStatus, {
|
||||
id: orderId,
|
||||
status: "shipped",
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventType).toBe("status_change");
|
||||
expect(events[0].source).toBe("admin");
|
||||
expect(events[0].fromStatus).toBe("confirmed");
|
||||
expect(events[0].toStatus).toBe("shipped");
|
||||
});
|
||||
|
||||
it("records the admin userId on the event", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const { asAdmin, adminId } = await setupAdmin(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await asAdmin.mutation(api.orders.updateStatus, {
|
||||
id: orderId,
|
||||
status: "processing",
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events[0].userId).toBe(adminId);
|
||||
});
|
||||
|
||||
it("records a separate event for each status change", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const { asAdmin } = await setupAdmin(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await asAdmin.mutation(api.orders.updateStatus, {
|
||||
id: orderId,
|
||||
status: "processing",
|
||||
});
|
||||
await asAdmin.mutation(api.orders.updateStatus, {
|
||||
id: orderId,
|
||||
status: "shipped",
|
||||
});
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.map((e) => e.toStatus)).toEqual(
|
||||
expect.arrayContaining(["processing", "shipped"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the order updatedAt timestamp", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const { asAdmin } = await setupAdmin(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
const before = Date.now();
|
||||
await asAdmin.mutation(api.orders.updateStatus, {
|
||||
id: orderId,
|
||||
status: "shipped",
|
||||
});
|
||||
|
||||
const order = await t.run(async (ctx) => ctx.db.get(orderId));
|
||||
expect(order!.updatedAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── orders.cancel → timeline ─────────────────────────────────────────────────
|
||||
|
||||
describe("orders.cancel - timeline events", () => {
|
||||
it("records a customer_cancel event on cancel", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await asCustomer.mutation(api.orders.cancel, { id: orderId });
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventType).toBe("customer_cancel");
|
||||
expect(events[0].source).toBe("customer_cancel");
|
||||
});
|
||||
|
||||
it("records fromStatus: confirmed and toStatus: cancelled", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await asCustomer.mutation(api.orders.cancel, { id: orderId });
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events[0].fromStatus).toBe("confirmed");
|
||||
expect(events[0].toStatus).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("records the customer userId on the event", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await asCustomer.mutation(api.orders.cancel, { id: orderId });
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(events[0].userId).toBe(userId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── orders.getTimeline ───────────────────────────────────────────────────────
|
||||
|
||||
describe("orders.getTimeline", () => {
|
||||
it("returns an empty array for an order with no timeline events", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
const events = await asCustomer.query(api.orders.getTimeline, { orderId });
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns events in ascending createdAt order", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("orderTimelineEvents", {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "stripe_webhook",
|
||||
toStatus: "confirmed",
|
||||
createdAt: 3000,
|
||||
});
|
||||
await ctx.db.insert("orderTimelineEvents", {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "admin",
|
||||
fromStatus: "confirmed",
|
||||
toStatus: "processing",
|
||||
createdAt: 1000,
|
||||
});
|
||||
await ctx.db.insert("orderTimelineEvents", {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "admin",
|
||||
fromStatus: "processing",
|
||||
toStatus: "shipped",
|
||||
createdAt: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
const events = await asCustomer.query(api.orders.getTimeline, { orderId });
|
||||
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events[0].createdAt).toBe(1000);
|
||||
expect(events[1].createdAt).toBe(2000);
|
||||
expect(events[2].createdAt).toBe(3000);
|
||||
});
|
||||
|
||||
it("throws if the order belongs to a different user", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
const asBob = t.withIdentity({
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
subject: "clerk_bob_tl",
|
||||
});
|
||||
await asBob.mutation(api.users.store, {});
|
||||
|
||||
await expect(
|
||||
asBob.query(api.orders.getTimeline, { orderId }),
|
||||
).rejects.toThrow("Unauthorized: order does not belong to you");
|
||||
});
|
||||
|
||||
it("admin can view the timeline for any order", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, variantId } = await setupCustomerAndProduct(t);
|
||||
const { asAdmin } = await setupAdmin(t);
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("orderTimelineEvents", {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "stripe_webhook",
|
||||
toStatus: "confirmed",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const events = await asAdmin.query(api.orders.getTimeline, { orderId });
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws if the order does not exist", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t);
|
||||
|
||||
// Create and delete an order to get a stale ID
|
||||
const orderId = await makeConfirmedOrder(t, userId, variantId);
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.delete(orderId);
|
||||
});
|
||||
|
||||
await expect(
|
||||
asCustomer.query(api.orders.getTimeline, { orderId }),
|
||||
).rejects.toThrow("Order not found");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user