/** * 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) { 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) { 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, 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) { 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"); }); });