| import { convexTest } from "convex-test"; |
| import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; |
| import { PRODUCT_CATALOG } from "../config/productCatalog"; |
| import schema from "../schema"; |
| import { internal } from "../_generated/api"; |
| import { |
| APIConnectionError, |
| APIConnectionTimeoutError, |
| APIUserAbortError, |
| AuthenticationError, |
| InternalServerError, |
| NotFoundError, |
| RateLimitError, |
| } from "dodopayments"; |
| import { |
| isDefinitiveDodoNotFound, |
| PENDING_PAYMENT_BLOCK_WINDOW_MS, |
| } from "../payments/billing"; |
| import { isNewerEvent } from "../payments/subscriptionHelpers"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| |
| |
| |
|
|
| const BASE_TIMESTAMP = new Date("2026-03-21T10:00:00Z").getTime(); |
| |
| |
| const FROZEN_NOW = new Date("2026-03-21T12:00:00Z").getTime(); |
|
|
| function makeSubscriptionPayload(overrides: Record<string, unknown> = {}) { |
| return { |
| type: "subscription.active", |
| business_id: "biz_test", |
| timestamp: "2026-03-21T10:00:00Z", |
| data: { |
| payload_type: "Subscription", |
| subscription_id: "sub_test_001", |
| product_id: "pdt_test_pro", |
| status: "active", |
| customer: { |
| customer_id: "cust_test_001", |
| email: "test@example.com", |
| name: "Test User", |
| }, |
| metadata: { wm_user_id: "test-user-001" }, |
| previous_billing_date: "2026-03-21T00:00:00Z", |
| next_billing_date: "2026-04-21T00:00:00Z", |
| ...overrides, |
| }, |
| }; |
| } |
|
|
| async function seedProductPlan(t: ReturnType<typeof convexTest>) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("productPlans", { |
| dodoProductId: "pdt_test_pro", |
| planKey: "pro_monthly", |
| displayName: "Pro Monthly", |
| isActive: true, |
| }); |
| }); |
| } |
|
|
| async function seedCustomer(t: ReturnType<typeof convexTest>) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("customers", { |
| userId: "test-user-001", |
| dodoCustomerId: "cust_test_001", |
| email: "test@example.com", |
| createdAt: BASE_TIMESTAMP, |
| updatedAt: BASE_TIMESTAMP, |
| }); |
| }); |
| } |
|
|
| async function processEvent( |
| t: ReturnType<typeof convexTest>, |
| webhookId: string, |
| eventType: string, |
| rawPayload: Record<string, unknown>, |
| timestamp: number, |
| ) { |
| await t.mutation(internal.payments.webhookMutations.processWebhookEvent, { |
| webhookId, |
| eventType, |
| rawPayload, |
| timestamp, |
| }); |
| } |
|
|
| |
| |
| |
|
|
| describe("malformed webhook transactional rollback", () => { |
| test("a crash mid-handler leaves ZERO subscription/entitlement/webhook rows", async () => { |
| const t = convexTest(schema, modules); |
| await seedProductPlan(t); |
| await seedCustomer(t); |
|
|
| |
| |
| |
| const malformed = makeSubscriptionPayload({ |
| customer: { customer_id: "cust_test_001", email: 12345, name: "Bad" }, |
| }); |
|
|
| |
| |
| |
| |
| |
| await expect( |
| processEvent(t, "wh_malformed_1", "subscription.active", malformed, BASE_TIMESTAMP), |
| ).rejects.toThrow(/trim is not a function/); |
|
|
| const { subs, ents, events } = await t.run(async (ctx) => ({ |
| subs: await ctx.db.query("subscriptions").collect(), |
| ents: await ctx.db.query("entitlements").collect(), |
| events: await ctx.db.query("webhookEvents").collect(), |
| })); |
| expect(subs).toHaveLength(0); |
| expect(ents).toHaveLength(0); |
| |
| expect(events).toHaveLength(0); |
| }); |
|
|
| test("Dodo's retry of the rolled-back webhookId then processes cleanly", async () => { |
| const t = convexTest(schema, modules); |
| await seedProductPlan(t); |
| await seedCustomer(t); |
|
|
| const malformed = makeSubscriptionPayload({ |
| customer: { customer_id: "cust_test_001", email: 12345, name: "Bad" }, |
| }); |
| await expect( |
| processEvent(t, "wh_retry_1", "subscription.active", malformed, BASE_TIMESTAMP), |
| ).rejects.toThrow(); |
|
|
| |
| await processEvent(t, "wh_retry_1", "subscription.active", makeSubscriptionPayload(), BASE_TIMESTAMP + 1); |
|
|
| const { subs, ents } = await t.run(async (ctx) => ({ |
| subs: await ctx.db.query("subscriptions").collect(), |
| ents: await ctx.db.query("entitlements").collect(), |
| })); |
| expect(subs).toHaveLength(1); |
| expect(subs[0]?.status).toBe("active"); |
| expect(ents).toHaveLength(1); |
| expect(ents[0]?.planKey).toBe("pro_monthly"); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| describe("concurrent dispatch of one webhookId", () => { |
| test("two in-flight deliveries activate exactly one subscription", async () => { |
| const t = convexTest(schema, modules); |
| await seedProductPlan(t); |
| await seedCustomer(t); |
|
|
| const payload = makeSubscriptionPayload(); |
| const settled = await Promise.allSettled([ |
| processEvent(t, "wh_concurrent_1", "subscription.active", payload, BASE_TIMESTAMP), |
| processEvent(t, "wh_concurrent_1", "subscription.active", payload, BASE_TIMESTAMP), |
| ]); |
| |
| |
| |
| expect(settled.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]); |
|
|
| const { subs, ents, events } = await t.run(async (ctx) => ({ |
| subs: await ctx.db.query("subscriptions").collect(), |
| ents: await ctx.db.query("entitlements").collect(), |
| events: await ctx.db.query("webhookEvents").collect(), |
| })); |
| expect(subs).toHaveLength(1); |
| expect(ents).toHaveLength(1); |
| expect(events).toHaveLength(1); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| describe("dodo error classification (real SDK instances)", () => { |
| test("a real NotFoundError is the ONLY definitive-404 shape", () => { |
| expect(isDefinitiveDodoNotFound(new NotFoundError(404, { detail: "gone" }, "Not Found", {}))) |
| .toBe(true); |
| }); |
|
|
| test.each([ |
| ["AuthenticationError (401)", new AuthenticationError(401, {}, "Unauthorized", {})], |
| ["RateLimitError (429)", new RateLimitError(429, {}, "Too Many Requests", {})], |
| ["InternalServerError (500)", new InternalServerError(500, {}, "Server Error", {})], |
| ["APIConnectionError (no status)", new APIConnectionError({ message: "socket hang up" })], |
| ["APIConnectionTimeoutError (no status)", new APIConnectionTimeoutError({ message: "timed out" })], |
| ["APIUserAbortError (no status)", new APIUserAbortError()], |
| ])("%s must NOT downgrade — it is transient/ambiguous", (_label, err) => { |
| expect(isDefinitiveDodoNotFound(err)).toBe(false); |
| }); |
|
|
| test("non-Error throwables never downgrade", () => { |
| for (const value of [null, undefined, 404, "404", { status: "404" }, {}]) { |
| expect(isDefinitiveDodoNotFound(value)).toBe(false); |
| } |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("equal-timestamp lifecycle ordering", () => { |
| test("isNewerEvent rejects an equal timestamp (replay must not reorder state)", () => { |
| expect(isNewerEvent(1_000, 1_000)).toBe(false); |
| expect(isNewerEvent(1_000, 999)).toBe(false); |
| expect(isNewerEvent(1_000, 1_001)).toBe(true); |
| }); |
|
|
| test("a cancellation carrying the SAME timestamp as the activation is ignored", async () => { |
| const t = convexTest(schema, modules); |
| await seedProductPlan(t); |
| await seedCustomer(t); |
|
|
| await processEvent(t, "wh_eq_1", "subscription.active", makeSubscriptionPayload(), BASE_TIMESTAMP); |
| await processEvent( |
| t, |
| "wh_eq_2", |
| "subscription.cancelled", |
| makeSubscriptionPayload({ status: "cancelled" }), |
| BASE_TIMESTAMP, |
| ); |
|
|
| const sub = await t.run(async (ctx) => |
| ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => q.eq("dodoSubscriptionId", "sub_test_001")) |
| .unique(), |
| ); |
| expect(sub?.status).toBe("active"); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("pending-payment block window boundary", () => { |
| async function seedPendingCharge(t: ReturnType<typeof convexTest>, occurredAt: number) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("paymentEvents", { |
| userId: "test-user-001", |
| dodoPaymentId: "pay_pending_001", |
| type: "charge", |
| amount: 999, |
| currency: "USD", |
| status: "requires_customer_action", |
| planKey: "pro_monthly", |
| rawPayload: {}, |
| occurredAt, |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| beforeEach(() => { |
| vi.useFakeTimers(); |
| vi.setSystemTime(FROZEN_NOW); |
| }); |
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| test("a pending payment exactly AT the window edge does not block (.gt is exclusive)", async () => { |
| const t = convexTest(schema, modules); |
| await seedPendingCharge(t, FROZEN_NOW - PENDING_PAYMENT_BLOCK_WINDOW_MS); |
|
|
| const blocking = await t.query(internal.payments.billing.getBlockingPendingPayment, { |
| userId: "test-user-001", |
| productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| }); |
| expect(blocking).toBeNull(); |
| }); |
|
|
| test("one millisecond inside the window still blocks — the edge is exactly windowStart", async () => { |
| const t = convexTest(schema, modules); |
| |
| |
| await seedPendingCharge(t, FROZEN_NOW - PENDING_PAYMENT_BLOCK_WINDOW_MS + 1); |
|
|
| const blocking = await t.query(internal.payments.billing.getBlockingPendingPayment, { |
| userId: "test-user-001", |
| productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| }); |
| expect(blocking).not.toBeNull(); |
| }); |
|
|
| test("a pending payment well inside the window still blocks the same tier group", async () => { |
| const t = convexTest(schema, modules); |
| await seedPendingCharge(t, FROZEN_NOW - PENDING_PAYMENT_BLOCK_WINDOW_MS + 60_000); |
|
|
| const blocking = await t.query(internal.payments.billing.getBlockingPendingPayment, { |
| userId: "test-user-001", |
| productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| }); |
| expect(blocking).not.toBeNull(); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("cancelled paid-through boundary at checkout", () => { |
| async function seedCancelledSub(t: ReturnType<typeof convexTest>, currentPeriodEnd: number) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("subscriptions", { |
| userId: "test-user-001", |
| dodoSubscriptionId: "sub_cancelled_boundary", |
| dodoProductId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| planKey: "pro_monthly", |
| status: "cancelled", |
| currentPeriodStart: currentPeriodEnd - 30 * 86_400_000, |
| currentPeriodEnd, |
| rawPayload: {}, |
| updatedAt: currentPeriodEnd - 86_400_000, |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| beforeEach(() => { |
| vi.useFakeTimers(); |
| vi.setSystemTime(FROZEN_NOW); |
| }); |
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| test("a cancellation whose period ends exactly NOW does not block (> is strict)", async () => { |
| const t = convexTest(schema, modules); |
| await seedCancelledSub(t, FROZEN_NOW); |
|
|
| const blocking = await t.query(internal.payments.billing.getCheckoutBlockingSubscription, { |
| userId: "test-user-001", |
| productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| }); |
| expect(blocking).toBeNull(); |
| }); |
|
|
| test("one millisecond of paid-through time still blocks — the edge is exactly now", async () => { |
| const t = convexTest(schema, modules); |
| await seedCancelledSub(t, FROZEN_NOW + 1); |
|
|
| const blocking = await t.query(internal.payments.billing.getCheckoutBlockingSubscription, { |
| userId: "test-user-001", |
| productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| }); |
| expect(blocking).toMatchObject({ status: "cancelled" }); |
| }); |
|
|
| test("a cancellation still paid-through blocks checkout in the same family", async () => { |
| const t = convexTest(schema, modules); |
| await seedCancelledSub(t, FROZEN_NOW + 5 * 60_000); |
|
|
| const blocking = await t.query(internal.payments.billing.getCheckoutBlockingSubscription, { |
| userId: "test-user-001", |
| productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| }); |
| expect(blocking).toMatchObject({ status: "cancelled" }); |
| }); |
| }); |
|
|