| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { convexTest } from "convex-test"; |
| import { describe, expect, test } from "vitest"; |
| import schema from "../schema"; |
| import { internal } from "../_generated/api"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| const DAY_MS = 24 * 60 * 60 * 1000; |
| const USER_ID = "user_3CSubUpdated"; |
| const PRODUCT_ID = "pdt_0Nbtt71uObulf7fGXhQup"; |
| const SUB_ID = "sub_test_updated"; |
|
|
| const CUSTOMER_ID = "cus_test"; |
|
|
| |
| async function seedActiveSub( |
| t: ReturnType<typeof convexTest>, |
| opts: { currentPeriodEnd: number; planKey?: string } = { currentPeriodEnd: Date.now() + 7 * DAY_MS }, |
| ) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("productPlans", { |
| dodoProductId: PRODUCT_ID, |
| planKey: opts.planKey ?? "pro_monthly", |
| displayName: "Pro Monthly", |
| isActive: true, |
| }); |
| |
| |
| await ctx.db.insert("customers", { |
| userId: USER_ID, |
| dodoCustomerId: CUSTOMER_ID, |
| email: "test@example.com", |
| createdAt: Date.now() - 365 * DAY_MS, |
| updatedAt: Date.now() - 365 * DAY_MS, |
| }); |
| await ctx.db.insert("subscriptions", { |
| userId: USER_ID, |
| dodoSubscriptionId: SUB_ID, |
| dodoProductId: PRODUCT_ID, |
| planKey: opts.planKey ?? "pro_monthly", |
| status: "active", |
| currentPeriodStart: Date.now() - 23 * DAY_MS, |
| currentPeriodEnd: opts.currentPeriodEnd, |
| rawPayload: {}, |
| updatedAt: Date.now() - 1000, |
| }); |
| await ctx.db.insert("entitlements", { |
| userId: USER_ID, |
| planKey: opts.planKey ?? "pro_monthly", |
| features: { |
| tier: 1, |
| maxDashboards: 10, |
| apiAccess: false, |
| apiRateLimit: 0, |
| prioritySupport: false, |
| exportFormats: ["csv", "pdf"], |
| }, |
| validUntil: opts.currentPeriodEnd, |
| updatedAt: Date.now() - 1000, |
| }); |
| }); |
| } |
|
|
| async function fireSubscriptionUpdated( |
| t: ReturnType<typeof convexTest>, |
| status: string, |
| opts: { eventTimestamp?: number; planKey?: string; cancelledAt?: number } = {}, |
| ) { |
| await t.mutation(internal.payments.webhookMutations.processWebhookEvent, { |
| webhookId: `msg_test_${SUB_ID}_updated_${status}_${Math.random().toString(36).slice(2, 6)}`, |
| eventType: "subscription.updated", |
| rawPayload: { |
| type: "subscription.updated", |
| data: { |
| subscription_id: SUB_ID, |
| product_id: PRODUCT_ID, |
| status, |
| customer: { customer_id: CUSTOMER_ID }, |
| metadata: { wm_user_id: USER_ID }, |
| previous_billing_date: new Date(Date.now() - 23 * DAY_MS).toISOString(), |
| next_billing_date: new Date(Date.now() + 7 * DAY_MS).toISOString(), |
| ...(opts.cancelledAt |
| ? { cancelled_at: new Date(opts.cancelledAt).toISOString() } |
| : {}), |
| }, |
| }, |
| timestamp: opts.eventTimestamp ?? Date.now(), |
| }); |
| } |
|
|
| async function readEntitlement(t: ReturnType<typeof convexTest>) { |
| return t.run(async (ctx) => |
| ctx.db |
| .query("entitlements") |
| .withIndex("by_userId", (q) => q.eq("userId", USER_ID)) |
| .first(), |
| ); |
| } |
|
|
| async function readSub(t: ReturnType<typeof convexTest>) { |
| return t.run(async (ctx) => |
| ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => q.eq("dodoSubscriptionId", SUB_ID)) |
| .unique(), |
| ); |
| } |
|
|
| |
| |
| |
|
|
| describe("subscription.updated β status='cancelled' (paid-through invariant)", () => { |
| test("preserves entitlement until currentPeriodEnd (mid-period cancellation)", async () => { |
| const t = convexTest(schema, modules); |
| const periodEnd = Date.now() + 7 * DAY_MS; |
| await seedActiveSub(t, { currentPeriodEnd: periodEnd }); |
|
|
| await fireSubscriptionUpdated(t, "cancelled", { cancelledAt: Date.now() }); |
|
|
| const sub = await readSub(t); |
| expect(sub?.status).toBe("cancelled"); |
| const ent = await readEntitlement(t); |
| |
| |
| expect(ent?.planKey).toBe("pro_monthly"); |
| expect(ent?.validUntil).toBe(periodEnd); |
| }); |
|
|
| test("does NOT touch entitlement on cancellation β only subscription.expired downgrades to free", async () => { |
| const t = convexTest(schema, modules); |
| const expiredPeriodEnd = Date.now() - 1 * DAY_MS; |
| await seedActiveSub(t, { currentPeriodEnd: expiredPeriodEnd }); |
|
|
| |
| |
| |
| await fireSubscriptionUpdated(t, "cancelled", { cancelledAt: Date.now() }); |
|
|
| const ent = await readEntitlement(t); |
| expect(ent?.planKey).toBe("pro_monthly"); |
| expect(ent?.validUntil).toBe(expiredPeriodEnd); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("subscription.updated β other statuses dispatch correctly", () => { |
| test("status='active' triggers handleSubscriptionActive (sub patched, entitlement re-derived)", async () => { |
| const t = convexTest(schema, modules); |
| await seedActiveSub(t); |
| |
| await fireSubscriptionUpdated(t, "active"); |
| const sub = await readSub(t); |
| expect(sub?.status).toBe("active"); |
| expect((sub?.rawPayload as { status?: string })?.status).toBe("active"); |
| }); |
|
|
| test("status='on_hold' triggers handleSubscriptionOnHold", async () => { |
| const t = convexTest(schema, modules); |
| await seedActiveSub(t); |
| await fireSubscriptionUpdated(t, "on_hold"); |
| const sub = await readSub(t); |
| expect(sub?.status).toBe("on_hold"); |
| }); |
|
|
| test("status='expired' triggers handleSubscriptionExpired (free downgrade)", async () => { |
| const t = convexTest(schema, modules); |
| await seedActiveSub(t); |
| await fireSubscriptionUpdated(t, "expired"); |
| const sub = await readSub(t); |
| expect(sub?.status).toBe("expired"); |
| const ent = await readEntitlement(t); |
| expect(ent?.planKey).toBe("free"); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("subscription.updated β defensive paths", () => { |
| test("unknown status: logs error + recomputes entitlement defensively (no crash)", async () => { |
| const t = convexTest(schema, modules); |
| await seedActiveSub(t); |
| |
| |
| |
| await fireSubscriptionUpdated(t, "paused"); |
| const sub = await readSub(t); |
| expect(sub).not.toBeNull(); |
| |
| |
| expect((sub?.rawPayload as { status?: string })?.status).toBe("paused"); |
| }); |
|
|
| test("missing status: defensive recompute (no crash)", async () => { |
| const t = convexTest(schema, modules); |
| await seedActiveSub(t); |
| |
| await fireSubscriptionUpdated(t, ""); |
| const sub = await readSub(t); |
| expect(sub).not.toBeNull(); |
| }); |
|
|
| test("stale eventTimestamp: rejected via isNewerEvent guard inherited from delegated handler", async () => { |
| const t = convexTest(schema, modules); |
| await seedActiveSub(t); |
| |
| const now = Date.now(); |
| await fireSubscriptionUpdated(t, "active", { eventTimestamp: now }); |
| const subAfterFirst = await readSub(t); |
| const updatedAtAfterFirst = subAfterFirst?.updatedAt; |
| |
| await fireSubscriptionUpdated(t, "active", { eventTimestamp: now - 60_000 }); |
| const subAfterSecond = await readSub(t); |
| expect(subAfterSecond?.updatedAt).toBe(updatedAtAfterFirst); |
| }); |
| }); |
|
|