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