← Provider replacement walkthrough

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

Source snapshot · 2026-09-09

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