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