← Provider replacement walkthrough

IAPKit · convex/subscriptions/query.test.ts

Source snapshot · 2026-09-09

SHA-256 f3621aa334a48fcdb4a0b71f0b5d2c1dd6a4ee44c6a3813b81012e5d12269b68
1import { describe, expect, it } from "vitest";2import { ConvexError } from "convex/values";3import type { Doc, Id } from "../_generated/dataModel";4import { testableFunction } from "../test.setup";5 6import {7  assertUserSubscriptionRowLimit,8  entitlementsV2 as registeredEntitlementsV2,9  MAX_USER_SUBSCRIPTION_ROWS,10  selectReportingMrr,11  shapeSubscriptionEvaluationSnapshot,12  shapeSubscriptionRow,13  shapeSubscriptionV2Row,14  subscriptionStatusV2 as registeredSubscriptionStatusV2,15} from "./query";16 17const subscriptionStatusV2 = testableFunction(registeredSubscriptionStatusV2);18const entitlementsV2 = testableFunction(registeredEntitlementsV2);19 20function subscriptionDoc(21  overrides: Partial<Doc<"subscriptions">>,22): Doc<"subscriptions"> {23  return {24    _id: "subscriptions_1" as Id<"subscriptions">,25    _creationTime: 0,26    projectId: "projects_1" as Id<"projects">,27    purchaseToken: "purchase_token_1",28    productId: "premium_monthly",29    platform: "IOS",30    state: "Active",31    startedAt: 1,32    updatedAt: 2,33    ...overrides,34  };35}36 37describe("selectReportingMrr", () => {38  it("uses only the reporting currency for the headline MRR", () => {39    const result = selectReportingMrr(40      [41        { currency: "EUR", mrrMicros: 8_500_000 },42        { currency: "USD", mrrMicros: 9_990_000 },43        { currency: "HUF", mrrMicros: 12_000_000 },44      ],45      "USD",46    );47 48    expect(result).toEqual({49      currency: "USD",50      mrrMicros: 9_990_000,51      excludedMrrByCurrency: [52        { currency: "EUR", mrrMicros: 8_500_000 },53        { currency: "HUF", mrrMicros: 12_000_000 },54      ],55    });56  });57 58  it("returns zero when the reporting currency has no matching MRR", () => {59    const result = selectReportingMrr(60      [61        { currency: "EUR", mrrMicros: 8_500_000 },62        { currency: "HUF", mrrMicros: 12_000_000 },63      ],64      "USD",65    );66 67    expect(result).toEqual({68      currency: "USD",69      mrrMicros: 0,70      excludedMrrByCurrency: [71        { currency: "EUR", mrrMicros: 8_500_000 },72        { currency: "HUF", mrrMicros: 12_000_000 },73      ],74    });75  });76 77  it("falls back to USD for invalid reporting currency input", () => {78    const result = selectReportingMrr(79      [80        { currency: "USD", mrrMicros: 9_990_000 },81        { currency: "EUR", mrrMicros: 8_500_000 },82      ],83      "US",84    );85 86    expect(result).toEqual({87      currency: "USD",88      mrrMicros: 9_990_000,89      excludedMrrByCurrency: [{ currency: "EUR", mrrMicros: 8_500_000 }],90    });91  });92});93 94describe("shapeSubscriptionRow", () => {95  it("exposes originalTransactionId for iOS subscription rows", () => {96    const row = shapeSubscriptionRow(97      subscriptionDoc({98        platform: "IOS",99        purchaseToken: "2000001177054625",100      }),101    );102 103    expect(row.purchaseToken).toBe("2000001177054625");104    expect(row.originalTransactionId).toBe("2000001177054625");105  });106 107  // Security debt, pinned so a removal is a deliberate decision: MAUI declares108  // purchaseToken `required` and drops the row when it is missing, so removing109  // it silently strips entitlement from every installed MAUI app.110  it("still carries the Play purchaseToken an installed MAUI app requires", () => {111    const row = shapeSubscriptionRow(112      subscriptionDoc({113        platform: "Android",114        purchaseToken: "play-token-1",115      }),116    );117 118    expect(row.purchaseToken).toBe("play-token-1");119    expect(row.originalTransactionId).toBeUndefined();120  });121});122 123describe("shapeSubscriptionV2Row", () => {124  it("omits both Android and Apple store credentials", () => {125    const row = shapeSubscriptionV2Row(126      subscriptionDoc({127        platform: "IOS",128        purchaseToken: "2000001177054625",129        userId: "user-1",130      }),131    );132 133    expect(row).toMatchObject({134      id: "subscriptions_1",135      productId: "premium_monthly",136      userId: "user-1",137    });138    expect(row).not.toHaveProperty("purchaseToken");139    expect(row).not.toHaveProperty("originalTransactionId");140  });141});142 143describe("v2 account-read authorization", () => {144  const db = {145    rows: {146      apiKeys: [147        {148          _id: "apiKeys_1",149          key: "openiap-kit_pk_mobile",150          keyType: "publishable",151          isActive: true,152          projectId: "projects_1",153          organizationId: "organizations_1",154        },155        {156          _id: "apiKeys_2",157          key: "openiap-kit_sk_backend",158          keyType: "secret",159          isActive: true,160          projectId: "projects_1",161          organizationId: "organizations_1",162        },163      ],164      projects: [165        {166          _id: "projects_1",167          organizationId: "organizations_1",168        },169      ],170      organizations: [{ _id: "organizations_1" }],171      subscriptions: [172        subscriptionDoc({173          userId: "user-1",174          expiresAt: 10,175        }),176      ],177    } as Record<string, Record<string, unknown>[]>,178    async get(id: string) {179      return (180        Object.values(this.rows)181          .flat()182          .find((row) => row._id === id) ?? null183      );184    },185    query(table: string) {186      const rows = this.rows[table] ?? [];187      return {188        withIndex: (_name: string, capture: (q: unknown) => unknown) => {189          const expected: Record<string, unknown> = {};190          const q: Record<string, (field: string, value: unknown) => unknown> =191            {};192          q.eq = (field, value) => {193            expected[field] = value;194            return q;195          };196          capture(q);197          const matches = () =>198            rows.filter((row) =>199              Object.entries(expected).every(200                ([field, value]) => row[field] === value,201              ),202            );203          return {204            first: async () => matches()[0] ?? null,205            order: () => ({206              take: async (limit: number) => matches().slice(0, limit),207            }),208          };209        },210      };211    },212  };213 214  it("rejects publishable keys inside Convex", async () => {215    const ctx = { db } as never;216    for (const query of [subscriptionStatusV2, entitlementsV2]) {217      await expect(218        query._handler(ctx, {219          apiKey: "openiap-kit_pk_mobile",220          userId: "user-1",221          now: 1,222        }),223      ).rejects.toSatisfy(224        (error: unknown) =>225          (error as { data?: { code?: string } }).data?.code ===226          "INSUFFICIENT_SCOPE",227      );228    }229  });230 231  it("rejects an unknown secret-shaped key instead of returning empty data", async () => {232    const ctx = { db } as never;233    for (const query of [subscriptionStatusV2, entitlementsV2]) {234      await expect(235        query._handler(ctx, {236          apiKey: "openiap-kit_sk_unknown",237          userId: "user-1",238          now: 1,239        }),240      ).rejects.toSatisfy(241        (error: unknown) =>242          (error as { data?: { code?: string } }).data?.code ===243          "INVALID_API_KEY",244      );245    }246  });247 248  it("uses the caller-supplied time so cache keys advance past expiry", async () => {249    const ctx = { db } as never;250    const beforeExpiry = await subscriptionStatusV2._handler(ctx, {251      apiKey: "openiap-kit_sk_backend",252      userId: "user-1",253      now: 9,254    });255    const atExpiry = await subscriptionStatusV2._handler(ctx, {256      apiKey: "openiap-kit_sk_backend",257      userId: "user-1",258      now: 10,259    });260    const entitlements = await entitlementsV2._handler(ctx, {261      apiKey: "openiap-kit_sk_backend",262      userId: "user-1",263      now: 10,264    });265 266    expect(beforeExpiry.active).toBe(true);267    expect(atExpiry.active).toBe(false);268    expect(entitlements.productIds).toEqual([]);269  });270});271 272describe("assertUserSubscriptionRowLimit", () => {273  it("accepts the documented 200-row boundary", () => {274    const rows = Array.from(275      { length: MAX_USER_SUBSCRIPTION_ROWS },276      (_, index) =>277        subscriptionDoc({278          _id: `subscriptions_${index}` as Id<"subscriptions">,279        }),280    );281 282    expect(() => assertUserSubscriptionRowLimit(rows)).not.toThrow();283  });284 285  it("fails closed on the single overflow-probe row", () => {286    const rows = Array.from(287      { length: MAX_USER_SUBSCRIPTION_ROWS + 1 },288      (_, index) =>289        subscriptionDoc({290          _id: `subscriptions_${index}` as Id<"subscriptions">,291        }),292    );293 294    try {295      assertUserSubscriptionRowLimit(rows);296      throw new Error("Expected the subscription row limit to fail");297    } catch (error) {298      expect(error).toBeInstanceOf(ConvexError);299      expect(300        (error as ConvexError<{ code: string; message: string }>).data,301      ).toEqual({302        code: "ENTITLEMENT_SNAPSHOT_TOO_LARGE",303        message:304          "This user has more than 200 subscription rows. Contact IAPKit support before retrying.",305      });306    }307  });308});309 310describe("shapeSubscriptionEvaluationSnapshot", () => {311  it("exposes only entitlement candidates and the latest status fallback", () => {312    const rows = [313      subscriptionDoc({314        _id: "subscriptions_latest" as Id<"subscriptions">,315        state: "Expired",316        updatedAt: 4,317        purchaseToken: "latest-token",318      }),319      subscriptionDoc({320        _id: "subscriptions_active" as Id<"subscriptions">,321        state: "Active",322        updatedAt: 3,323        purchaseToken: "active-token",324      }),325      subscriptionDoc({326        _id: "subscriptions_refunded" as Id<"subscriptions">,327        state: "Refunded",328        updatedAt: 2,329        purchaseToken: "historical-token",330      }),331    ];332 333    const snapshot = shapeSubscriptionEvaluationSnapshot(rows);334 335    expect(snapshot.candidates).toHaveLength(1);336    expect(snapshot.candidates[0]?.id).toBe("subscriptions_active");337    expect(snapshot.candidates[0]?.createdAt).toBe(0);338    expect(snapshot.fallback?.createdAt).toBe(0);339    expect(snapshot.candidates[0]?.purchaseToken).toBe("active-token");340    expect(snapshot.fallback?.purchaseToken).toBe("latest-token");341    expect(JSON.stringify(snapshot)).not.toContain("historical-token");342  });343});344