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