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