← Provider replacement walkthrough

IAPKit · convex/commerce/spec.conformance.test.ts

Source snapshot · 2026-10-21

SHA-256 6bbecb843d7bd13eb9ea568d99aebba02bb906e1e38dc57a57c08b19c98dcb23
1// Proves IAPKit conforms to the OpenIAP Commerce Protocol.2//3// The specification is the authority: its JSON Schema validates payloads kit4// builds, and its published vocabulary is compared with kit's. Neither list is5// restated here, so an event type kit gains but the spec lacks fails this file.6 7import Ajv from "ajv/dist/2020.js";8import {9  KNOWN_COMMERCE_EVENT_TYPES as SPEC_EVENT_TYPES,10  COMMERCE_EVENT_VERSION,11  DATA_PROVENANCE as SPEC_DATA_PROVENANCE,12  EXTENSION_LIMITS,13  KNOWN_STORES as SPEC_KNOWN_STORES,14  SUBSCRIPTION_STATES as SPEC_SUBSCRIPTION_STATES,15  WEBHOOK,16  commerceEventSchema,17  primitivesSchema,18} from "@hyodotdev/openiap-commerce-protocol";19import { describe, expect, it } from "vitest";20 21import type { Doc } from "../_generated/dataModel";22import { PROVIDER_CAPABILITIES } from "./capabilities";23import {24  COMMERCE_ENVIRONMENTS,25  COMMERCE_EVENT_SCHEMA_VERSION,26  COMMERCE_EVENT_TYPES,27  COMMERCE_STORES,28  DATA_PROVENANCE_VALUES,29  commerceEventTypeForTransition,30  MAX_EXTENSION_ENTRIES,31  MAX_EXTENSION_KEY_LENGTH,32  MAX_EXTENSION_VALUE_LENGTH,33  TRANSITION_TO_EVENT,34  type LifecycleTransition,35} from "./contract";36import { readFileSync } from "node:fs";37import { createRequire } from "node:module";38import { fileURLToPath } from "node:url";39 40import {41  applySubscriptionTransition,42  entitlementActive,43} from "../subscriptions/stateMachine";44import {45  mapAppleNotificationType,46  mapGoogleSubscriptionNotificationType,47} from "../webhooks/shared";48 49import { buildEventPayload } from "./deliveryState";50import { emitCommerceEvent } from "./internal";51import {52  EVENT_ID_HEADER,53  DELIVERY_ID_HEADER,54  SIGNATURE_HEADER,55  CONTENT_TYPE,56  SIGNATURE_TOLERANCE_SECONDS,57  signPayloadWithRotation,58  TIMESTAMP_HEADER,59} from "./signing";60 61// kit is ESM, so `require` exists only through the test runner's interop shim.62// Resolve the spec's published subpaths the way any ESM consumer would.63const resolveSpec = createRequire(import.meta.url).resolve;64 65function validator() {66  const ajv = new Ajv({ strict: true, allErrors: true });67  ajv.addSchema(primitivesSchema, "primitives.schema.json");68  ajv.addSchema(commerceEventSchema, "commerce-event.schema.json");69  return ajv;70}71 72const validateEvent = () =>73  validator().getSchema("commerce-event.schema.json")!;74 75/** A stored row shaped exactly as `emitCommerceEvent` writes one. */76function storedEvent(overrides: Record<string, unknown> = {}) {77  return {78    _id: "commerceEvents_1",79    _creationTime: 0,80    projectId: "projects_1",81    eventType: "subscription.renewed",82    eventVersion: COMMERCE_EVENT_SCHEMA_VERSION,83    store: "apple",84    environment: "production",85    userId: "user_1",86    productId: "premium.monthly",87    transactionId: "2000000912345678",88    originalTransactionId: "2000000811111111",89    subscriptionId: "subscriptions_1",90    subscription: {91      state: "Active",92      productId: "premium.monthly",93      expiresAt: 1_758_979_200_000,94      renewsAt: 1_758_979_200_000,95      willRenew: true,96    },97    entitlementActive: true,98    currency: "USD",99    amountMicros: 9_990_000,100    amountProvenance: "store",101    sourceEventId: "webhookEvents_1",102    sourceStoreNotificationId: "8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e",103    occurredAt: 1_756_300_800_000,104    processedAt: 1_756_300_801_420,105    ...overrides,106  } as unknown as Doc<"commerceEvents">;107}108 109describe("IAPKit conforms to the OpenIAP Commerce Protocol", () => {110  it("declares only event types the specification names", () => {111    for (const eventType of COMMERCE_EVENT_TYPES) {112      expect(SPEC_EVENT_TYPES).toContain(eventType);113    }114  });115 116  it("emits the event version the specification names", () => {117    expect(COMMERCE_EVENT_SCHEMA_VERSION).toBe(COMMERCE_EVENT_VERSION);118  });119 120  it("reads the extension bounds the specification sets", () => {121    // Imported, not pinned: they constrain the sanitizer and no receiver122    // decodes them. Both sides read one schema, so this proves only that kit123    // maps the same three fields; the spec package pins the values.124    expect({125      maxEntries: MAX_EXTENSION_ENTRIES,126      maxKeyLength: MAX_EXTENSION_KEY_LENGTH,127      maxValueLength: MAX_EXTENSION_VALUE_LENGTH,128    }).toEqual(EXTENSION_LIMITS);129  });130 131  it("maps the transport constants the specification publishes", () => {132    // Both sides read one file, so this only catches kit reading the wrong key.133    // The golden in contract.test.ts is what catches a protocol rename.134    expect(SIGNATURE_HEADER).toBe(WEBHOOK.signatureHeader);135    expect(TIMESTAMP_HEADER).toBe(WEBHOOK.timestampHeader);136    expect(EVENT_ID_HEADER).toBe(WEBHOOK.eventIdHeader);137    expect(DELIVERY_ID_HEADER).toBe(WEBHOOK.deliveryIdHeader);138    expect(SIGNATURE_TOLERANCE_SECONDS).toBe(WEBHOOK.toleranceSeconds);139    expect(CONTENT_TYPE).toBe(WEBHOOK.contentType);140  });141 142  const signatureVectors = JSON.parse(143    readFileSync(144      resolveSpec(145        "@hyodotdev/openiap-commerce-protocol/vectors/signatures.json",146      ),147      "utf8",148    ),149  ) as {150    cases: {151      name: string;152      secret: string;153      previousSecret?: string;154      timestamp: number;155      body: string;156      expected: string;157    }[];158  };159 160  // SPEC.md 9.4.2 fixes the signed material. The other signing tests are161  // self-relative, so only these vectors fail if signing changes wholesale.162  it.each(signatureVectors.cases.map((c) => [c.name, c] as const))(163    "reproduces the %s signature vector",164    async (_name, vector) => {165      await expect(166        signPayloadWithRotation(167          { current: vector.secret, previous: vector.previousSecret },168          vector.timestamp,169          vector.body,170        ),171      ).resolves.toBe(vector.expected);172    },173  );174});175 176describe("built payloads validate against the specification schema", () => {177  it("a minimal event with no subscription and no price validates", () => {178    const validate = validateEvent();179    const payload = buildEventPayload(180      storedEvent({181        store: "amazon",182        subscription: undefined,183        subscriptionId: undefined,184        entitlementActive: undefined,185        currency: undefined,186        amountMicros: undefined,187        amountProvenance: undefined,188        userId: undefined,189        transactionId: undefined,190        originalTransactionId: undefined,191        sourceStoreNotificationId: undefined,192      }),193    );194    const ok = validate(payload);195    expect(validate.errors ?? [], JSON.stringify(validate.errors)).toEqual([]);196    expect(ok).toBe(true);197  });198 199  it("kit's emitter produces lifecycle events for Apple and Google only", async () => {200    // storeForPlatform maps a two-member platform enum. Horizon and Amazon201    // reach kit through verification, not notifications, as their capability202    // descriptor declares.203    for (const [platform, expectedStore] of [204      ["IOS", "apple"],205      ["Android", "google"],206    ] as const) {207      const { ctx, inserted } = emitterContext();208      await emitCommerceEvent(ctx as never, {209        projectId: "projects_1" as never,210        transition: "Renewed",211        active: true,212        previouslyActive: true,213        sourceEvent: { ...(richSource as object), platform } as never,214      });215      expect(inserted[0].doc.store).toBe(expectedStore);216    }217  });218 219  it("omits the snapshot rather than inventing an entitlement gate", () => {220    // The stored gate is optional. Emitting `active: false` for a row that221    // never recorded one would assert no access for something unknown.222    const payload = buildEventPayload(223      storedEvent({ entitlementActive: undefined }),224    );225    expect(payload.subscription).toBeUndefined();226    expect(validateEvent()(payload)).toBe(true);227  });228 229  it("carries the gate through when the row recorded one", () => {230    const payload = buildEventPayload(231      storedEvent({ entitlementActive: false }),232    );233    expect(payload.subscription?.active).toBe(false);234  });235 236  it("omits price entirely when the store asserted no amount, rather than sending zero", () => {237    const payload = buildEventPayload(238      storedEvent({ currency: undefined, amountMicros: undefined }),239    );240    expect(payload.price).toBeUndefined();241  });242});243 244describe("the subscription state vocabulary matches", () => {245  it("kit's own state vocabulary is one the specification declares", () => {246    // Checking the spec's enum against itself proves nothing; the claim is that247    // kit's union is a subset of it.248    const source = readFileSync(249      fileURLToPath(new URL("../webhooks/shared.ts", import.meta.url)),250      "utf8",251    );252    const start = source.indexOf("export type SubscriptionState");253    const block = source.slice(start, source.indexOf(";", start));254    const declared = [...block.matchAll(/"([A-Za-z]+)"/g)].map((m) => m[1]);255    expect(declared.length).toBeGreaterThan(5);256    for (const state of declared) {257      expect(SPEC_SUBSCRIPTION_STATES, `${state} is undeclared`).toContain(258        state,259      );260    }261  });262 263  it("declares no provenance, store, or environment the specification lacks", () => {264    // Closed value space: the two lists must be identical.265    expect([...DATA_PROVENANCE_VALUES].sort()).toEqual(266      [...SPEC_DATA_PROVENANCE].sort(),267    );268    // Open value spaces: everything kit emits must still be a value the269    // specification names today.270    for (const store of COMMERCE_STORES) {271      expect(SPEC_KNOWN_STORES, `${store} is undeclared`).toContain(store);272    }273    const specEnvironments = (274      primitivesSchema as unknown as {275        $defs: { Environment: { examples: readonly string[] } };276      }277    ).$defs.Environment.examples;278    for (const environment of COMMERCE_ENVIRONMENTS) {279      expect(specEnvironments, `${environment} is undeclared`).toContain(280        environment,281      );282    }283  });284 285  it("rejects a state the specification does not declare", () => {286    const validate = validateEvent();287    const payload = buildEventPayload(288      storedEvent({289        subscription: { state: "Hibernating", productId: "premium.monthly" },290      }),291    );292    expect(validate(payload)).toBe(false);293  });294});295 296describe("kit's capabilities agree with the published descriptor", () => {297  const descriptor = JSON.parse(298    readFileSync(299      resolveSpec(300        "@hyodotdev/openiap-commerce-protocol/examples/provider-capabilities.json",301      ),302      "utf8",303    ),304  ) as {305    eventTypes: string[];306    stores: Record<307      string,308      Record<string, { provider: boolean; implementation: boolean }>309    >;310  };311 312  const FIELDS: [keyof (typeof PROVIDER_CAPABILITIES)["apple"], string][] = [313    ["supportsInitialValidation", "initialValidation"],314    ["supportsServerNotifications", "serverNotifications"],315    ["supportsSubscriptions", "subscriptions"],316    ["supportsRenewalEvents", "renewalEvents"],317    ["supportsRefundEvents", "refundEvents"],318    ["supportsExpiration", "expiration"],319    ["supportsReconciliation", "reconciliation"],320    ["supportsEntitlements", "entitlements"],321    ["supportsRevenueAmount", "revenueAmount"],322  ];323 324  it("publishes the event vocabulary kit implements", () => {325    expect([...descriptor.eventTypes].sort()).toEqual(326      [...COMMERCE_EVENT_TYPES].sort(),327    );328  });329 330  // Implementation support does not define what the store itself offers.331  it.each(Object.keys(PROVIDER_CAPABILITIES))(332    "%s implementation axis matches kit",333    (store) => {334      const caps =335        PROVIDER_CAPABILITIES[store as keyof typeof PROVIDER_CAPABILITIES];336      for (const [kitField, specField] of FIELDS) {337        expect(338          descriptor.stores[store][specField].implementation,339          `${store}.${specField}`,340        ).toBe(caps[kitField]);341      }342    },343  );344 345  it("never claims to implement more than the provider offers", () => {346    for (const [store, entry] of Object.entries(descriptor.stores)) {347      for (const [name, support] of Object.entries(entry)) {348        if (support.implementation) {349          expect(support.provider, `${store}.${name}`).toBe(true);350        }351      }352    }353  });354});355 356describe("kit reproduces the specification's lifecycle vectors", () => {357  const vectors = JSON.parse(358    readFileSync(359      resolveSpec(360        "@hyodotdev/openiap-commerce-protocol/generated/vectors/lifecycle.json",361      ),362      "utf8",363    ),364  ) as {365    entitlement: {366      cases: {367        name: string;368        state: string;369        expiresAt?: number;370        occurredAt: number;371        processedAt: number;372        entitled: boolean;373      }[];374    };375    emission: {376      cases: {377        name: string;378        lifecycleEvent: string | null;379        entitledBefore: boolean;380        entitledAfter: boolean;381        emit: string[];382      }[];383    };384    binding: {385      cases: {386        name: string;387        entitledAtBinding: boolean;388        emit: string[];389      }[];390    };391  };392 393  it.each(vectors.entitlement.cases.map((c) => [c.name, c] as const))(394    "entitlement: %s",395    (_name, testCase) => {396      const sub = { state: testCase.state, expiresAt: testCase.expiresAt };397      expect(entitlementActive(sub, testCase.processedAt)).toBe(398        testCase.entitled,399      );400      // The one kit-side exception (isMisrecordedApplePass): an Active iOS row401      // with no expiry is a legacy non-renewing pass, not a subscription.402      const kitException =403        testCase.state === "Active" && testCase.expiresAt === undefined;404      expect(405        entitlementActive({ ...sub, platform: "IOS" }, testCase.processedAt),406      ).toBe(kitException ? false : testCase.entitled);407      expect(408        entitlementActive(409          { ...sub, platform: "Android" },410          testCase.processedAt,411        ),412      ).toBe(testCase.entitled);413    },414  );415 416  // Drives the real `emitCommerceEvent`. Deriving the expected list from417  // entitledBefore/entitledAfter would restate the generator's formula and418  // could never fail.419  const KIT_TRANSITIONS = Object.keys(420    TRANSITION_TO_EVENT,421  ) as LifecycleTransition[];422 423  /** The kit transition whose mapping produces this specification event. */424  function transitionFor(lifecycleEvent: string | null) {425    const match = KIT_TRANSITIONS.find(426      (t) => commerceEventTypeForTransition(t) === lifecycleEvent,427    );428    if (!match) throw new Error(`no kit transition emits ${lifecycleEvent}`);429    return match;430  }431 432  it.each(vectors.emission.cases.map((c) => [c.name, c] as const))(433    "emission: %s",434    async (_name, testCase) => {435      const { ctx, inserted } = emitterContext();436      await emitCommerceEvent(ctx as never, {437        projectId: "projects_1" as never,438        transition: transitionFor(testCase.lifecycleEvent),439        active: testCase.entitledAfter,440        previouslyActive: testCase.entitledBefore,441        sourceEvent: richSource,442        subscription: {443          state: "Active",444          productId: "premium.monthly",445          userId: "user_5e91a7",446        },447      });448      const emitted = inserted449        .filter((row) => row.table === "commerceEvents")450        .map((row) => row.doc.eventType);451      expect(emitted).toEqual(testCase.emit);452    },453  );454 455  it.each(vectors.binding.cases.map((c) => [c.name, c] as const))(456    "first binding: %s",457    async (_name, testCase) => {458      const { ctx, inserted } = emitterContext();459      await emitCommerceEvent(ctx as never, {460        projectId: "projects_1" as never,461        transition: null,462        active: testCase.entitledAtBinding,463        previouslyActive: false,464        sourceEvent: richSource,465        subscription: {466          state: testCase.entitledAtBinding ? "Active" : "Expired",467          productId: "premium.monthly",468          userId: "user_5e91a7",469        },470      });471      expect(472        inserted473          .filter((row) => row.table === "commerceEvents")474          .map((row) => row.doc.eventType),475      ).toEqual(testCase.emit);476    },477  );478 479  it("maps every transition kit produces onto an event the vectors cover", () => {480    const covered = new Set(481      vectors.emission.cases.map((c) => c.lifecycleEvent).filter(Boolean),482    );483    const transitions = KIT_TRANSITIONS.filter((t) => t !== "Ignored");484    for (const transition of transitions) {485      const event = commerceEventTypeForTransition(transition);486      expect(event, `${transition} must map to an event`).toBeTruthy();487      expect(covered, `${event} must be covered by a vector`).toContain(event);488    }489  });490 491  it("emits nothing for the no-op transition, as the vectors require", () => {492    expect(commerceEventTypeForTransition("Ignored")).toBeNull();493    expect(commerceEventTypeForTransition(null)).toBeNull();494  });495});496 497describe("kit can produce everything the store mapping promises", () => {498  const mapping = JSON.parse(499    readFileSync(500      resolveSpec(501        "@hyodotdev/openiap-commerce-protocol/examples/store-event-mapping.json",502      ),503      "utf8",504    ),505  ) as {506    stores: Record<507      string,508      {509        notificationChannel: string | null;510        mappings: { event: string | null }[];511      }512    >;513  };514 515  it("never claims to consume a channel the store does not publish", () => {516    // notificationChannel is a fact about the store; the capability is a fact517    // about kit. Implementation implies provider, never the reverse.518    for (const [store, entry] of Object.entries(mapping.stores)) {519      const caps =520        PROVIDER_CAPABILITIES[store as keyof typeof PROVIDER_CAPABILITIES];521      if (!caps.supportsServerNotifications) continue;522      expect(523        entry.notificationChannel,524        `${store}: kit consumes notifications the mapping says do not exist`,525      ).not.toBeNull();526    }527  });528 529  it("consumes the Amazon channel declared by the store mapping", () => {530    expect(mapping.stores.amazon.notificationChannel).not.toBeNull();531    expect(PROVIDER_CAPABILITIES.amazon.supportsServerNotifications).toBe(true);532  });533 534  it("supports the lifecycle categories explicitly declared by the mapping", () => {535    for (const [store, entry] of Object.entries(mapping.stores)) {536      const caps =537        PROVIDER_CAPABILITIES[store as keyof typeof PROVIDER_CAPABILITIES];538      if (539        entry.mappings.some(({ event }) => event?.startsWith("subscription."))540      )541        expect(caps.supportsSubscriptions, `${store}`).toBe(true);542      if (entry.mappings.some(({ event }) => event === "subscription.renewed"))543        expect(caps.supportsRenewalEvents, `${store}`).toBe(true);544    }545  });546});547 548/**549 * Stands in for a MutationCtx. `destinations` lets a test exercise fan-out and550 * the per-destination event-type filter.551 */552function emitterContext(553  destinations: { _id: string; eventTypes?: string[] }[] = [],554) {555  const inserted: { table: string; doc: Record<string, unknown> }[] = [];556  const byId = new Map<string, Record<string, unknown>>();557  const rowsFor = (table: string) =>558    table === "outboundDestinations" ? destinations : [];559  const queryFor = (table: string) => {560    const q: Record<string, unknown> = {561      withIndex: () => q,562      collect: async () => rowsFor(table),563      unique: async () => rowsFor(table)[0] ?? null,564      first: async () => rowsFor(table)[0] ?? null,565    };566    return q;567  };568  const ctx = {569    db: {570      insert: async (table: string, doc: Record<string, unknown>) => {571        const id = `${table}_${inserted.length + 1}`;572        const row = { ...doc, _id: id, _creationTime: 0 };573        inserted.push({ table, doc: row });574        byId.set(id, row);575        return id;576      },577      patch: async (id: string, patchDoc: Record<string, unknown>) => {578        Object.assign(byId.get(id) ?? {}, patchDoc);579      },580      get: async (id: string) => byId.get(id) ?? null,581      query: (table: string) => queryFor(table),582    },583  };584  return { ctx, inserted };585}586 587/** A source event complete enough that the emitter fills every canonical field. */588const richSource = {589  _id: "webhookEvents_1",590  platform: "IOS",591  environment: "Production",592  occurredAt: 1_756_300_800_000,593  sourceNotificationId: "8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e",594  productId: "premium.monthly",595  purchaseToken: "2000000811111111",596  currency: "USD",597  priceAmountMicros: 9_990_000,598  amountProvenance: "store",599} as never;600 601describe("kit's cancellation vocabulary is one the specification names", () => {602  // The tokens live in a schema description on one side and a TypeScript union603  // on the other, with nothing between them. A store this backend never sees604  // may add more, so the check is containment, not equality.605  const described = JSON.parse(606    readFileSync(607      resolveSpec(608        "@hyodotdev/openiap-commerce-protocol/generated/schemas/commerce-event.schema.json",609      ),610      "utf8",611    ),612  ).properties.subscription.properties.cancellationReason.description as string;613 614  const KIT_REASONS = [615    "UserCanceled",616    "BillingError",617    "PriceIncreaseDeclined",618    "ProductUnavailable",619    "Refunded",620    "Other",621  ];622 623  it("matches the union the receiver declares", () => {624    const source = readFileSync(625      fileURLToPath(new URL("../webhooks/shared.ts", import.meta.url)),626      "utf8",627    );628    const start = source.indexOf("export type WebhookCancellationReason");629    const block = source.slice(start, source.indexOf(";", start));630    const declared = [...block.matchAll(/"([A-Za-z]+)"/g)].map((m) => m[1]);631    expect(declared.sort()).toEqual([...KIT_REASONS].sort());632  });633 634  it("is named in full by the specification", () => {635    for (const reason of KIT_REASONS) {636      expect(described, `${reason} is unnamed by the spec`).toContain(reason);637    }638  });639});640 641describe("what the emitter writes is a valid specification event", () => {642  // End to end: what emitCommerceEvent inserts must pass buildEventPayload and643  // the published schema. The suites above validate a hand-written row or644  // check only event-type strings.645  const validate = validateEvent();646 647  it("a full lifecycle emission survives buildEventPayload and the schema", async () => {648    const { ctx, inserted } = emitterContext();649    await emitCommerceEvent(ctx as never, {650      projectId: "projects_1" as never,651      transition: "Renewed",652      active: true,653      previouslyActive: false,654      sourceEvent: richSource,655      subscriptionId: "subscriptions_1" as never,656      subscription: {657        state: "Active",658        productId: "premium.monthly",659        expiresAt: 1_758_979_200_000,660        renewsAt: 1_758_979_200_000,661        willRenew: true,662        userId: "user_5e91a7",663      },664    });665 666    const events = inserted.filter((row) => row.table === "commerceEvents");667    expect(events).toHaveLength(2);668    for (const row of events) {669      const payload = buildEventPayload(row.doc as never);670      const ok = validate(payload);671      expect(validate.errors ?? [], JSON.stringify(validate.errors)).toEqual(672        [],673      );674      expect(ok).toBe(true);675      // The internal identifiers the emitter stores must not reach the wire.676      const wire = payload as unknown as Record<string, unknown>;677      expect(wire.subscriptionId).toBeUndefined();678      expect(wire.sourceEventId).toBeUndefined();679      expect(wire._id).toBeUndefined();680    }681  });682 683  it("prices the lifecycle event only, never both events of one transition", async () => {684    const { ctx, inserted } = emitterContext();685    await emitCommerceEvent(ctx as never, {686      projectId: "projects_1" as never,687      transition: "Started",688      active: true,689      previouslyActive: false,690      sourceEvent: richSource,691      subscription: {692        state: "Active",693        productId: "premium.monthly",694        userId: "user_5e91a7",695      },696    });697    const events = inserted.filter((row) => row.table === "commerceEvents");698    expect(events.map((row) => row.doc.eventType)).toEqual([699      "subscription.started",700      "entitlement.granted",701    ]);702    expect(events[0].doc.amountMicros).toBe(9_990_000);703    expect(events[1].doc.amountMicros).toBeUndefined();704    expect(events[1].doc.currency).toBeUndefined();705    expect(events[1].doc.amountProvenance).toBeUndefined();706  });707 708  it("omits the amount when the source recorded no provenance", async () => {709    // "store" is the only provenance the contract calls authoritative, so710    // defaulting to it would present an unknown as a store assertion.711    const { ctx, inserted } = emitterContext();712    await emitCommerceEvent(ctx as never, {713      projectId: "projects_1" as never,714      transition: "Renewed",715      active: true,716      previouslyActive: true,717      sourceEvent: {718        ...(richSource as object),719        amountProvenance: undefined,720      } as never,721    });722    expect(inserted[0].doc.amountProvenance).toBeUndefined();723    expect(buildEventPayload(inserted[0].doc as never).price).toBeUndefined();724  });725 726  it("puts the amount on the entitlement event when there is no lifecycle event", async () => {727    // A receipt-bootstrapped first purchase flips the gate with no lifecycle728    // change. Dropping the amount there would lose it entirely.729    const { ctx, inserted } = emitterContext();730    await emitCommerceEvent(ctx as never, {731      projectId: "projects_1" as never,732      transition: null,733      active: true,734      previouslyActive: false,735      sourceEvent: richSource,736      subscription: {737        state: "Active",738        productId: "premium.monthly",739        userId: "user_5e91a7",740      },741    });742    const events = inserted.filter((row) => row.table === "commerceEvents");743    expect(events.map((row) => row.doc.eventType)).toEqual([744      "entitlement.granted",745    ]);746    expect(events[0].doc.amountMicros).toBe(9_990_000);747  });748 749  it("carries the store's own notification id, not the emitter's row id", async () => {750    const { ctx, inserted } = emitterContext();751    await emitCommerceEvent(ctx as never, {752      projectId: "projects_1" as never,753      transition: "Renewed",754      active: true,755      previouslyActive: true,756      sourceEvent: richSource,757    });758    const payload = buildEventPayload(inserted[0].doc as never);759    expect(payload.sourceStoreEventId).toBe(760      "8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e",761    );762    expect(payload.price).toEqual({763      currency: "USD",764      amountMicros: 9_990_000,765      provenance: "store",766    });767  });768 769  it("fans out to a subscribed destination and skips an unsubscribed one", async () => {770    const { ctx, inserted } = emitterContext([771      { _id: "outboundDestinations_1" },772      { _id: "outboundDestinations_2", eventTypes: ["subscription.expired"] },773    ]);774    await emitCommerceEvent(ctx as never, {775      projectId: "projects_1" as never,776      transition: "Renewed",777      active: true,778      previouslyActive: true,779      sourceEvent: richSource,780    });781    const deliveries = inserted.filter(782      (row) => row.table === "outboundDeliveries",783    );784    // The filtering destination asked for a different event type.785    expect(deliveries).toHaveLength(1);786    expect(deliveries[0].doc.destinationId).toBe("outboundDestinations_1");787  });788});789 790describe("every mapping row produces the event it promises", () => {791  // Runs each mapping row through the pipeline. Otherwise a row can claim any792  // event in the vocabulary and stay green.793  type Row = {794    storeNotification: string;795    storeNotificationCode?: string;796    storeSubtype?: string | null;797    whenNoPriorStoreEvent?: boolean;798    whenPreviousState?: string[];799    event: string | null;800  };801  const mapping = JSON.parse(802    readFileSync(803      resolveSpec(804        "@hyodotdev/openiap-commerce-protocol/examples/store-event-mapping.json",805      ),806      "utf8",807    ),808  ) as { stores: Record<string, { mappings: Row[] }> };809 810  /** Notifications carried on their own message field, not a type enum. */811  const SPECIAL: Record<string, string | null> = {812    voidedPurchaseNotification: "PurchaseRefunded",813    testNotification: "TestNotification",814  };815 816  function internalTypeFor(store: string, row: Row): string | null {817    if (row.storeNotification in SPECIAL) return SPECIAL[row.storeNotification];818    if (store === "apple") {819      return mapAppleNotificationType(820        row.storeNotification,821        row.storeSubtype ?? null,822      );823    }824    return mapGoogleSubscriptionNotificationType(825      Number(row.storeNotificationCode),826    );827  }828 829  function emittedFor(store: string, row: Row): string | null {830    const internalType = internalTypeFor(store, row);831    if (!internalType) return null;832    // Drives the state machine directly, so it models "no prior store event"833    // as no record. The other case, a record with no store history, needs the834    // full handler; subscriptions/internal.test.ts covers it ("starts rather835    // than recovers when a record exists with no store history").836    const current = row.whenNoPriorStoreEvent837      ? null838      : {839          state: (row.whenPreviousState?.[0] ?? "Active") as never,840          productId: "premium.monthly",841          expiresAt: 9_999_999_999_999,842        };843    const result = applySubscriptionTransition(current, {844      type: internalType,845      productId: "premium.monthly",846      platform: store === "apple" ? "IOS" : "Android",847      purchaseToken: "token",848      expiresAt: 9_999_999_999_999,849    } as never);850    return commerceEventTypeForTransition(result.transition);851  }852 853  const cases = Object.entries(mapping.stores).flatMap(([store, entry]) =>854    entry.mappings.map(855      (row) =>856        [857          `${store} ${row.storeNotification}${row.storeSubtype ? `/${row.storeSubtype}` : ""}${row.whenNoPriorStoreEvent ? " (no store history)" : row.whenPreviousState ? ` (was ${row.whenPreviousState[0]})` : ""}`,858          store,859          row,860        ] as const,861    ),862  );863 864  // The implementation's code table, read from its source. Google sends a865  // number and the name lives only in a trailing comment; without this, two866  // swapped names stay green while an implementer maps the wrong notification.867  const googleCodeNames = (() => {868    const source = readFileSync(869      fileURLToPath(new URL("../webhooks/shared.ts", import.meta.url)),870      "utf8",871    );872    const start = source.indexOf("const GOOGLE_SUB_TYPE_MAP");873    expect(start, "GOOGLE_SUB_TYPE_MAP not found").toBeGreaterThan(-1);874    // End at the object's own closing brace, not the next declaration: a -1875    // indexOf would silently swallow the rest of the file into the table.876    const end = source.indexOf("\n};", start);877    expect(end, "GOOGLE_SUB_TYPE_MAP is unterminated").toBeGreaterThan(start);878    const block = source.slice(start, end);879    const names = new Map<string, string>();880    const codes = new Set<string>();881    for (const line of block.split("\n")) {882      const entry = line.match(/^\s*(\d+):\s*"[^"]*",/);883      if (!entry) continue;884      codes.add(entry[1]);885      const named = line.match(/\/\/\s*([A-Z][A-Z_]+)/);886      // Every entry must name itself, or the table's names go unchecked.887      expect(named, `code ${entry[1]} has no name comment`).not.toBeNull();888      if (named) names.set(entry[1], named[1]);889    }890    expect(names.size).toBe(codes.size);891    return names;892  })();893 894  it("names every Google notification the way the store numbers it", () => {895    for (const row of mapping.stores.google.mappings) {896      if (!row.storeNotificationCode) continue;897      const expected = googleCodeNames.get(row.storeNotificationCode);898      expect(899        expected,900        `code ${row.storeNotificationCode} is not in the map`,901      ).toBeDefined();902      expect(row.storeNotification, `code ${row.storeNotificationCode}`).toBe(903        expected,904      );905    }906  });907 908  it("gives every Google notification code a row", () => {909    const covered = new Set(910      mapping.stores.google.mappings.map((row) => row.storeNotificationCode),911    );912    for (const code of googleCodeNames.keys()) {913      expect(covered, `code ${code} has no row`).toContain(code);914    }915  });916 917  it("names only Apple notifications the receiver recognises", () => {918    // A row for a type the receiver rejects would document a mapping that never919    // happens — and with `event: null` it would look like a deliberate no-op.920    for (const row of mapping.stores.apple.mappings) {921      expect(922        mapAppleNotificationType(923          row.storeNotification as never,924          (row.storeSubtype ?? null) as never,925        ),926        `${row.storeNotification} is not handled by the receiver`,927      ).not.toBeNull();928    }929  });930 931  // Measure coverage against the receiver's own switch cases, as the Google932  // code check does; comparing the mapping with itself could never fail.933  const appleHandledTypes = (() => {934    const source = readFileSync(935      fileURLToPath(new URL("../webhooks/shared.ts", import.meta.url)),936      "utf8",937    );938    const start = source.indexOf("function mapAppleNotificationType");939    const end = source.indexOf("\n}", start);940    const body = source.slice(start, end);941    return new Set(942      [...body.matchAll(/case "([A-Z_]+)":/g)].map((match) => match[1]),943    );944  })();945 946  it("gives every Apple notification the receiver handles a row", () => {947    expect(appleHandledTypes.size).toBeGreaterThan(10);948    const covered = new Set(949      mapping.stores.apple.mappings.map((row) => row.storeNotification),950    );951    for (const type of appleHandledTypes) {952      expect(covered, `${type} has no row`).toContain(type);953    }954  });955 956  it.each(cases)("%s", (_label, store, row) => {957    expect(emittedFor(store, row)).toBe(row.event);958  });959});960