← Provider replacement walkthrough

IAPKit · convex/subscriptions/query.test.ts

Source snapshot · 2026-10-21

SHA-256 74e122c658a01e23c82838f95cff80a44d84e4cbcb25328a361e548ce059dbf4
1import { afterEach, describe, expect, it, vi } from "vitest";2import { ConvexError } from "convex/values";3import type { Doc, Id } from "../_generated/dataModel";4import { testableFunction } from "../test.setup";5import { STATS_DEFINITIONS_VERSION, statsContributionFor } from "./stats";6import { MemoryDb } from "../test.memoryDb";7import { metricsSummary } from "./query";8 9import {10  assertUserSubscriptionRowLimit,11  entitlementsV2 as registeredEntitlementsV2,12  entitlements as registeredEntitlements,13  MAX_USER_SUBSCRIPTION_ROWS,14  selectReportingMrr,15  shapeSubscriptionEvaluationSnapshot,16  shapeSubscriptionRow,17  shapeSubscriptionV2Row,18  subscriptionStatusV2 as registeredSubscriptionStatusV2,19  subscriptionStatus as registeredSubscriptionStatus,20  subscriptionEvaluationSnapshot as registeredSubscriptionEvaluationSnapshot,21} from "./query";22 23const subscriptionStatusV2 = testableFunction(registeredSubscriptionStatusV2);24const entitlementsV2 = testableFunction(registeredEntitlementsV2);25const legacyAccountReads = [26  testableFunction(registeredSubscriptionStatus),27  testableFunction(registeredEntitlements),28  testableFunction(registeredSubscriptionEvaluationSnapshot),29];30 31function subscriptionDoc(32  overrides: Partial<Doc<"subscriptions">>,33): Doc<"subscriptions"> {34  return {35    _id: "subscriptions_1" as Id<"subscriptions">,36    _creationTime: 0,37    projectId: "projects_1" as Id<"projects">,38    purchaseToken: "purchase_token_1",39    productId: "premium_monthly",40    platform: "IOS",41    state: "Active",42    startedAt: 1,43    updatedAt: 2,44    ...overrides,45  };46}47 48describe("selectReportingMrr", () => {49  it("uses only the reporting currency for the headline MRR", () => {50    const result = selectReportingMrr(51      [52        { currency: "EUR", mrrMicros: 8_500_000 },53        { currency: "USD", mrrMicros: 9_990_000 },54        { currency: "HUF", mrrMicros: 12_000_000 },55      ],56      "USD",57    );58 59    expect(result).toEqual({60      currency: "USD",61      mrrMicros: 9_990_000,62      excludedMrrByCurrency: [63        { currency: "EUR", mrrMicros: 8_500_000 },64        { currency: "HUF", mrrMicros: 12_000_000 },65      ],66    });67  });68 69  it("returns zero when the reporting currency has no matching MRR", () => {70    const result = selectReportingMrr(71      [72        { currency: "EUR", mrrMicros: 8_500_000 },73        { currency: "HUF", mrrMicros: 12_000_000 },74      ],75      "USD",76    );77 78    expect(result).toEqual({79      currency: "USD",80      mrrMicros: 0,81      excludedMrrByCurrency: [82        { currency: "EUR", mrrMicros: 8_500_000 },83        { currency: "HUF", mrrMicros: 12_000_000 },84      ],85    });86  });87 88  it("falls back to USD for invalid reporting currency input", () => {89    const result = selectReportingMrr(90      [91        { currency: "USD", mrrMicros: 9_990_000 },92        { currency: "EUR", mrrMicros: 8_500_000 },93      ],94      "US",95    );96 97    expect(result).toEqual({98      currency: "USD",99      mrrMicros: 9_990_000,100      excludedMrrByCurrency: [{ currency: "EUR", mrrMicros: 8_500_000 }],101    });102  });103});104 105describe("shapeSubscriptionRow", () => {106  it("exposes originalTransactionId for iOS subscription rows", () => {107    const row = shapeSubscriptionRow(108      subscriptionDoc({109        platform: "IOS",110        purchaseToken: "2000001177054625",111      }),112    );113 114    expect(row.purchaseToken).toBe("2000001177054625");115    expect(row.originalTransactionId).toBe("2000001177054625");116  });117 118  // Legacy server responses preserve the required MAUI field; mobile keys cannot read them.119  it("still carries the Play purchaseToken an installed MAUI app requires", () => {120    const row = shapeSubscriptionRow(121      subscriptionDoc({122        platform: "Android",123        purchaseToken: "play-token-1",124      }),125    );126 127    expect(row.purchaseToken).toBe("play-token-1");128    expect(row.originalTransactionId).toBeUndefined();129  });130});131 132describe("shapeSubscriptionV2Row", () => {133  it("keeps Amazon identity on tokenless v2 reads without adding fields to the v1 shape", () => {134    const sub = subscriptionDoc({135      platform: "Android",136      store: "amazon",137      purchaseToken: "amazon:private-evidence",138    });139    expect(shapeSubscriptionV2Row(sub)).toMatchObject({ store: "amazon" });140    expect(shapeSubscriptionV2Row(sub)).not.toHaveProperty("purchaseToken");141    expect(shapeSubscriptionRow(sub)).not.toHaveProperty("store");142  });143  it("omits both Android and Apple store credentials", () => {144    const row = shapeSubscriptionV2Row(145      subscriptionDoc({146        platform: "IOS",147        purchaseToken: "2000001177054625",148        userId: "user-1",149      }),150    );151 152    expect(row).toMatchObject({153      id: "subscriptions_1",154      productId: "premium_monthly",155      userId: "user-1",156    });157    expect(row).not.toHaveProperty("purchaseToken");158    expect(row).not.toHaveProperty("originalTransactionId");159  });160});161 162describe("account-read authorization", () => {163  const db = {164    rows: {165      apiKeys: [166        {167          _id: "apiKeys_1",168          key: "openiap-kit_pk_mobile",169          keyType: "publishable",170          isActive: true,171          projectId: "projects_1",172          organizationId: "organizations_1",173        },174        {175          _id: "apiKeys_2",176          key: "openiap-kit_sk_backend",177          keyType: "secret",178          isActive: true,179          projectId: "projects_1",180          organizationId: "organizations_1",181        },182      ],183      projects: [184        {185          _id: "projects_1",186          organizationId: "organizations_1",187        },188      ],189      organizations: [{ _id: "organizations_1" }],190      subscriptions: [191        subscriptionDoc({192          userId: "user-1",193          expiresAt: 10,194        }),195        subscriptionDoc({196          _id: "subscriptions_pass" as Id<"subscriptions">,197          userId: "user-pass",198          platform: "IOS",199          state: "Active",200          expiresAt: undefined,201        }),202        subscriptionDoc({203          _id: "subscriptions_grace" as Id<"subscriptions">,204          userId: "user-grace",205          platform: "IOS",206          state: "InGracePeriod",207          expiresAt: undefined,208        }),209      ],210    } as Record<string, Record<string, unknown>[]>,211    async get(id: string) {212      return (213        Object.values(this.rows)214          .flat()215          .find((row) => row._id === id) ?? null216      );217    },218    query(table: string) {219      const rows = this.rows[table] ?? [];220      return {221        withIndex: (_name: string, capture: (q: unknown) => unknown) => {222          const expected: Record<string, unknown> = {};223          const q: Record<string, (field: string, value: unknown) => unknown> =224            {};225          q.eq = (field, value) => {226            expected[field] = value;227            return q;228          };229          capture(q);230          const matches = () =>231            rows.filter((row) =>232              Object.entries(expected).every(233                ([field, value]) => row[field] === value,234              ),235            );236          return {237            first: async () => matches()[0] ?? null,238            order: () => ({239              take: async (limit: number) => matches().slice(0, limit),240            }),241          };242        },243      };244    },245  };246 247  it("rejects publishable keys inside Convex", async () => {248    const ctx = { db } as never;249    for (const query of [250      subscriptionStatusV2,251      entitlementsV2,252      ...legacyAccountReads,253    ]) {254      await expect(255        query._handler(ctx, {256          apiKey: "openiap-kit_pk_mobile",257          userId: "user-1",258          now: 1,259        }),260      ).rejects.toSatisfy(261        (error: unknown) =>262          (error as { data?: { code?: string } }).data?.code ===263          "INSUFFICIENT_SCOPE",264      );265    }266  });267 268  it("rejects an unknown secret-shaped key instead of returning empty data", async () => {269    const ctx = { db } as never;270    for (const query of [271      subscriptionStatusV2,272      entitlementsV2,273      ...legacyAccountReads,274    ]) {275      await expect(276        query._handler(ctx, {277          apiKey: "openiap-kit_sk_unknown",278          userId: "user-1",279          now: 1,280        }),281      ).rejects.toSatisfy(282        (error: unknown) =>283          (error as { data?: { code?: string } }).data?.code ===284          "INVALID_API_KEY",285      );286    }287  });288 289  it.each([290    ["production", ["shared", "event-derived"], "prod"],291    ["sandbox", ["shared", "test", "local-test"], "sandbox-only"],292    ["all", ["shared", "test", "local-test", "event-derived"], "sandbox-only"],293  ] as const)(294    "filters %s before status selection and product deduplication",295    async (environment, products, statusId) => {296      const previous = db.rows.subscriptions;297      db.rows.subscriptions = [298        subscriptionDoc({299          _id: "prod" as Id<"subscriptions">,300          userId: "mixed",301          productId: "shared",302          updatedAt: 10,303          environment: "Production",304        }),305        subscriptionDoc({306          _id: "sandbox" as Id<"subscriptions">,307          userId: "mixed",308          productId: "shared",309          updatedAt: 20,310          environment: "Sandbox",311        }),312        subscriptionDoc({313          _id: "sandbox-only" as Id<"subscriptions">,314          userId: "mixed",315          productId: "test",316          updatedAt: 30,317          environment: "Sandbox",318        }),319        subscriptionDoc({320          _id: "legacy" as Id<"subscriptions">,321          userId: "mixed",322          productId: "unknown",323          updatedAt: 50,324        }),325        subscriptionDoc({326          _id: "xcode" as Id<"subscriptions">,327          userId: "mixed",328          productId: "local-test",329          updatedAt: 15,330          environment: "Xcode",331        }),332        subscriptionDoc({333          _id: "event" as Id<"subscriptions">,334          userId: "mixed",335          productId: "event-derived",336          updatedAt: 5,337          lastEventSource: { environment: "Production" },338        }),339        subscriptionDoc({340          _id: "expired" as Id<"subscriptions">,341          userId: "mixed",342          productId: "expired",343          updatedAt: 99,344          expiresAt: 1,345          environment: "Production",346        }),347      ].map((row) => ({ ...row, expiresAt: row.expiresAt ?? 10 }));348      try {349        const args = {350          apiKey: "openiap-kit_sk_backend",351          userId: "mixed",352          now: 2,353          environment,354        };355        expect(await entitlementsV2._handler({ db }, args)).toMatchObject({356          environmentScope: environment,357          productIds: [...products],358        });359        expect(await subscriptionStatusV2._handler({ db }, args)).toMatchObject(360          {361            environmentScope: environment,362            active: true,363            subscription: { id: statusId },364          },365        );366        const legacyArgs = {367          apiKey: args.apiKey,368          userId: args.userId,369          now: args.now,370        };371        expect(372          await subscriptionStatusV2._handler({ db }, legacyArgs),373        ).toMatchObject({ active: true, subscription: { id: "legacy" } });374        expect(375          await entitlementsV2._handler({ db }, legacyArgs),376        ).not.toHaveProperty("environmentScope");377      } finally {378        db.rows.subscriptions = previous;379      }380    },381  );382 383  it("uses the caller-supplied time so cache keys advance past expiry", async () => {384    const ctx = { db } as never;385    const beforeExpiry = await subscriptionStatusV2._handler(ctx, {386      apiKey: "openiap-kit_sk_backend",387      userId: "user-1",388      now: 9,389    });390    const atExpiry = await subscriptionStatusV2._handler(ctx, {391      apiKey: "openiap-kit_sk_backend",392      userId: "user-1",393      now: 10,394    });395    const entitlements = await entitlementsV2._handler(ctx, {396      apiKey: "openiap-kit_sk_backend",397      userId: "user-1",398      now: 10,399    });400 401    expect(beforeExpiry.active).toBe(true);402    expect(atExpiry.active).toBe(false);403    expect(entitlements.productIds).toEqual([]);404  });405 406  it("never entitles an iOS row without an expiry (a non-renewing pass)", async () => {407    const ctx = { db } as never;408    const read = { apiKey: "openiap-kit_sk_backend", userId: "user-pass" };409 410    const status = await subscriptionStatusV2._handler(ctx, {411      ...read,412      now: 1,413    });414    const entitlements = await entitlementsV2._handler(ctx, {415      ...read,416      now: 1,417    });418 419    expect(status.active).toBe(false);420    expect(entitlements.productIds).toEqual([]);421  });422 423  it("keeps an iOS grace period without a deadline entitled", async () => {424    const ctx = { db } as never;425    const status = await subscriptionStatusV2._handler(ctx, {426      apiKey: "openiap-kit_sk_backend",427      userId: "user-grace",428      now: 1,429    });430 431    expect(status.active).toBe(true);432  });433});434 435describe("statsContributionFor", () => {436  it("keeps an iOS row without an expiry out of active counts and MRR", () => {437    const pass = subscriptionDoc({438      state: "Active",439      expiresAt: undefined,440      currency: "USD",441      priceAmountMicros: 9_990_000,442    });443 444    expect(statsContributionFor(pass, "P1M", 5)).toBeNull();445    expect(446      statsContributionFor({ ...pass, expiresAt: 10 }, "P1M", 5),447    ).toMatchObject({ state: "Active", mrrMicros: 9_990_000 });448  });449});450 451describe("assertUserSubscriptionRowLimit", () => {452  it("accepts the documented 200-row boundary", () => {453    const rows = Array.from(454      { length: MAX_USER_SUBSCRIPTION_ROWS },455      (_, index) =>456        subscriptionDoc({457          _id: `subscriptions_${index}` as Id<"subscriptions">,458        }),459    );460 461    expect(() => assertUserSubscriptionRowLimit(rows)).not.toThrow();462  });463 464  it("fails closed on the single overflow-probe row", () => {465    const rows = Array.from(466      { length: MAX_USER_SUBSCRIPTION_ROWS + 1 },467      (_, index) =>468        subscriptionDoc({469          _id: `subscriptions_${index}` as Id<"subscriptions">,470        }),471    );472 473    try {474      assertUserSubscriptionRowLimit(rows);475      throw new Error("Expected the subscription row limit to fail");476    } catch (error) {477      expect(error).toBeInstanceOf(ConvexError);478      expect(479        (error as ConvexError<{ code: string; message: string }>).data,480      ).toEqual({481        code: "ENTITLEMENT_SNAPSHOT_TOO_LARGE",482        message:483          "This user has more than 200 subscription rows. Contact IAPKit support before retrying.",484      });485    }486  });487});488 489describe("shapeSubscriptionEvaluationSnapshot", () => {490  it("exposes only entitlement candidates and the latest status fallback", () => {491    const rows = [492      subscriptionDoc({493        _id: "subscriptions_latest" as Id<"subscriptions">,494        state: "Expired",495        updatedAt: 4,496        purchaseToken: "latest-token",497      }),498      subscriptionDoc({499        _id: "subscriptions_active" as Id<"subscriptions">,500        state: "Active",501        expiresAt: 10,502        updatedAt: 3,503        purchaseToken: "active-token",504      }),505      subscriptionDoc({506        _id: "subscriptions_refunded" as Id<"subscriptions">,507        state: "Refunded",508        updatedAt: 2,509        purchaseToken: "historical-token",510      }),511    ];512 513    const snapshot = shapeSubscriptionEvaluationSnapshot(rows);514 515    expect(snapshot.candidates).toHaveLength(1);516    expect(snapshot.candidates[0]?.id).toBe("subscriptions_active");517    expect(snapshot.candidates[0]?.createdAt).toBe(0);518    expect(snapshot.fallback?.createdAt).toBe(0);519    expect(snapshot.candidates[0]?.purchaseToken).toBe("active-token");520    expect(snapshot.fallback?.purchaseToken).toBe("latest-token");521    expect(JSON.stringify(snapshot)).not.toContain("historical-token");522  });523 524  it("drops a legacy non-renewing pass but keeps a store-governed row", () => {525    const snapshot = shapeSubscriptionEvaluationSnapshot([526      subscriptionDoc({527        _id: "subscriptions_pass" as Id<"subscriptions">,528        state: "Active",529        expiresAt: undefined,530      }),531      subscriptionDoc({532        _id: "subscriptions_recovered" as Id<"subscriptions">,533        state: "Active",534        expiresAt: undefined,535        lastEventId: "webhookEvents_1" as Id<"webhookEvents">,536      }),537    ]);538 539    expect(snapshot.candidates.map((row) => row.id)).toEqual([540      "subscriptions_recovered",541    ]);542  });543});544 545describe("metricsSummary byte budgets", () => {546  afterEach(() => vi.useRealTimers());547  function fixture() {548    vi.useFakeTimers({ toFake: ["Date"] });549    vi.setSystemTime(100_000);550    const db = new MemoryDb();551    const projectId = "projects_metrics";552    db.seed("organizations", { _id: "organizations_metrics" });553    db.seed("projects", {554      _id: projectId,555      organizationId: "organizations_metrics",556    });557    db.seed("apiKeys", {558      key: "openiap-kit_sk_metrics_budget",559      keyType: "secret",560      isActive: true,561      projectId,562      organizationId: "organizations_metrics",563    });564    return {565      db,566      projectId,567      args: { apiKey: "openiap-kit_sk_metrics_budget", refreshAt: Date.now() },568    };569  }570  it("keeps the reactive page range stable across clock and cache changes", async () => {571    const { db, projectId, args } = fixture();572    db.seed("subscriptions", {573      projectId,574      productId: "premium",575      platform: "Android",576      state: "Active",577      environment: "Production",578      purchaseToken: "reactive-budget",579      startedAt: 1,580      updatedAt: Date.now(),581      expiresAt: Date.now() + 100,582    });583    db.seed("subscriptionStats", {584      projectId,585      activeSubs: 1,586      inGracePeriod: 0,587      inBillingRetry: 0,588      mrrMicros: 0,589    });590    db.seed("revenueMetricsRunStatus", {591      projectId,592      statsDefinitionsVersion: STATS_DEFINITIONS_VERSION,593      statsUpdatedAt: Date.now(),594      statsValidUntil: Date.now() + 100,595    });596    let journal: string | undefined;597    const original = db.query.bind(db);598    vi.spyOn(db, "query").mockImplementation((table) => {599      const query = original(table);600      if (table !== "subscriptions") return query;601      const withIndex = query.withIndex.bind(query);602      const paginate = query.paginate.bind(query);603      const bounds: unknown[] = [];604      query.withIndex = (name, range) =>605        withIndex(name, (q) => {606          type Range = typeof q;607          const track =608            (method: keyof Range) =>609            (field: string, value: Parameters<Range["eq"]>[1]): Range => {610              bounds.push([method, field, value]);611              q[method](field, value);612              return tracked;613            };614          const tracked: Range = {615            eq: track("eq"),616            gt: track("gt"),617            gte: track("gte"),618            lt: track("lt"),619            lte: track("lte"),620          };621          bounds.push(name);622          return range?.(tracked);623        });624      // Convex reuses its page journal when the same reactive query reruns.625      query.paginate = async (options) => {626        expect(options).toMatchObject({627          maximumRowsRead: 10_000,628          maximumBytesRead: 8 * 1024 * 1024,629        });630        const signature = JSON.stringify(bounds);631        if (journal !== undefined && journal !== signature)632          throw new Error("InvalidCursor: reactive query range changed");633        journal = signature;634        return paginate(options);635      };636      return query;637    });638    const read = () => testableFunction(metricsSummary)._handler({ db }, args);639    expect((await read()).activeSubs).toBe(1);640    vi.setSystemTime(100_050);641    expect((await read()).activeSubs).toBe(1);642    vi.setSystemTime(100_101);643    expect((await read()).activeSubs).toBe(0);644  });645 646  it("marks a required reactive page split incomplete", async () => {647    const { db, args } = fixture();648    const original = db.query.bind(db);649    vi.spyOn(db, "query").mockImplementation((table) => {650      const query = original(table);651      if (table === "subscriptions") {652        const paginate = query.paginate.bind(query);653        query.paginate = async (options) => ({654          ...(await paginate(options)),655          isDone: true,656          pageStatus: "SplitRequired",657        });658      }659      return query;660    });661    const result = await testableFunction(metricsSummary)._handler(662      { db },663      args,664    );665    expect(result.stats.state).toBe("incomplete");666    expect(result.recentCountsTruncated).toBe(true);667  });668  it("marks a byte-limited subscription page incomplete", async () => {669    const { db, projectId, args } = fixture();670    const description = "x".repeat(100_000);671    for (let i = 0; i < 100; i++)672      db.seed("subscriptions", {673        projectId,674        productId: "premium",675        platform: "Android",676        state: "Active",677        purchaseToken: `budget-${i}`,678        environment: "Production",679        startedAt: 1,680        updatedAt: Date.now(),681        cancellationReason: description,682      });683    const result = await testableFunction(metricsSummary)._handler(684      { db },685      args,686    );687    expect(result.stats.state).toBe("incomplete");688    expect(result.activeSubs).toBeLessThan(100);689    expect(result.recentCountsTruncated).toBe(true);690  });691  it("bounds distinct catalog lookups even when every product is missing", async () => {692    const { db, projectId, args } = fixture();693    for (let i = 0; i < 300; i++)694      db.seed("subscriptions", {695        projectId,696        productId: `missing-${i}`,697        platform: "Android",698        state: "Active",699        purchaseToken: `missing-${i}`,700        environment: "Production",701        startedAt: 1,702        updatedAt: Date.now(),703      });704    const reads = vi.spyOn(db, "query");705    const result = await testableFunction(metricsSummary)._handler(706      { db },707      args,708    );709    expect(result.stats.state).toBe("incomplete");710    expect(711      reads.mock.calls.filter(([table]) => table === "products"),712    ).toHaveLength(250);713    expect(result.recentCountsTruncated).toBe(false);714  });715 716  it("bounds catalog reads even when all subscriptions fit", async () => {717    const { db, projectId, args } = fixture();718    for (let i = 0; i < 8; i++) {719      const productId = `premium-${i}`;720      db.seed("products", {721        projectId,722        platform: "Android",723        productId,724        billingPeriod: "P1M",725        description: "x".repeat(900_000),726      });727      db.seed("subscriptions", {728        projectId,729        productId,730        platform: "Android",731        state: "Active",732        purchaseToken: `catalog-${i}`,733        environment: "Production",734        startedAt: 1,735        updatedAt: Date.now(),736      });737    }738    const reads = vi.spyOn(db, "query");739    const result = await testableFunction(metricsSummary)._handler(740      { db },741      args,742    );743    expect(result.stats.state).toBe("incomplete");744    expect(745      reads.mock.calls.filter(([table]) => table === "products"),746    ).toHaveLength(3);747    expect(result.recentCountsTruncated).toBe(false);748  });749});750