@mpgd/game-services 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,613 @@
1
+ import { appsInTossPurchaseCallbackEvidenceSchema, createAppsInTossProductGrantCallback, createAppsInTossProductGrantVerificationPort, createAppsInTossProductionEvidenceVerifier, createAppsInTossPurchaseCallbackEvidence, createAppsInTossRewardCallbackEvidence, } from './apps-in-toss-evidence-verification';
2
+ import { createGameServicesBackend, createInMemoryGameServicesStore, InMemoryGameServicesStore, } from './server';
3
+ export const appsInTossProductionEvidenceConformanceScenarios = [
4
+ 'callback-only-fail-closed',
5
+ 'purchase-product-grant-callback',
6
+ 'purchase-authority-retry',
7
+ 'purchase-pending-order-restore',
8
+ 'purchase-authority-matching',
9
+ 'purchase-timestamp-normalization',
10
+ 'reward-authority-retry-and-replay',
11
+ 'reward-timestamp-validation',
12
+ 'authority-errors-and-reward-matching',
13
+ ];
14
+ const catalog = {
15
+ version: 'conformance',
16
+ products: [
17
+ {
18
+ id: 'CONFORMANCE_COINS',
19
+ type: 'consumable',
20
+ grant: {
21
+ type: 'currency',
22
+ currency: 'coin',
23
+ amount: 10,
24
+ },
25
+ platformProductIds: {
26
+ ait: 'ait.conformance.coins',
27
+ },
28
+ },
29
+ ],
30
+ };
31
+ const placements = {
32
+ version: 'conformance',
33
+ placements: [
34
+ {
35
+ id: 'CONFORMANCE_REWARD',
36
+ type: 'rewarded',
37
+ reward: {
38
+ type: 'continue',
39
+ amount: 1,
40
+ },
41
+ frequencyCap: {
42
+ cooldownSeconds: 1,
43
+ maxPerSession: 1,
44
+ },
45
+ platformPlacementIds: {
46
+ ait: 'ait.conformance.reward',
47
+ },
48
+ },
49
+ ],
50
+ };
51
+ const scenarioRunners = {
52
+ 'callback-only-fail-closed': runCallbackOnlyFailClosedScenario,
53
+ 'purchase-product-grant-callback': runPurchaseProductGrantCallbackScenario,
54
+ 'purchase-authority-retry': runPurchaseAuthorityRetryScenario,
55
+ 'purchase-pending-order-restore': runPurchasePendingOrderRestoreScenario,
56
+ 'purchase-authority-matching': runPurchaseAuthorityMatchingScenario,
57
+ 'purchase-timestamp-normalization': runPurchaseTimestampNormalizationScenario,
58
+ 'reward-authority-retry-and-replay': runRewardAuthorityRetryAndReplayScenario,
59
+ 'reward-timestamp-validation': runRewardTimestampValidationScenario,
60
+ 'authority-errors-and-reward-matching': runAuthorityErrorsAndRewardMatchingScenario,
61
+ };
62
+ /**
63
+ * Runs deterministic client-callback, authority, and ledger scenarios without
64
+ * requiring Apps in Toss credentials or platform SDK imports.
65
+ */
66
+ export async function runAppsInTossProductionEvidenceConformance(input = {}) {
67
+ const createVerifier = input.createVerifier ?? createAppsInTossProductionEvidenceVerifier;
68
+ const now = input.now ?? '2030-01-02T03:04:05.000Z';
69
+ const passedScenarios = [];
70
+ for (const scenario of appsInTossProductionEvidenceConformanceScenarios) {
71
+ const runScenario = scenarioRunners[scenario];
72
+ try {
73
+ await runScenario(createVerifier, now);
74
+ }
75
+ catch (error) {
76
+ throw new Error(`Apps in Toss production evidence conformance failed: ${scenario}.`, {
77
+ cause: error,
78
+ });
79
+ }
80
+ passedScenarios.push(scenario);
81
+ }
82
+ return { passedScenarios };
83
+ }
84
+ export function createAppsInTossProductionEvidenceAuthorityFixture() {
85
+ const purchaseResults = [];
86
+ const rewardResults = [];
87
+ const purchaseInputs = [];
88
+ const rewardInputs = [];
89
+ return {
90
+ purchaseAuthority: {
91
+ async getOrderStatus(input) {
92
+ purchaseInputs.push(input);
93
+ return shiftFixtureResult(purchaseResults, {
94
+ decision: 'rejected',
95
+ reason: 'AIT_PURCHASE_AUTHORITY_FIXTURE_EXHAUSTED',
96
+ });
97
+ },
98
+ },
99
+ rewardAuthority: {
100
+ async verifyReward(input) {
101
+ rewardInputs.push(input);
102
+ return shiftFixtureResult(rewardResults, {
103
+ decision: 'rejected',
104
+ reason: 'AIT_REWARD_AUTHORITY_FIXTURE_EXHAUSTED',
105
+ });
106
+ },
107
+ },
108
+ enqueuePurchaseResult(result) {
109
+ purchaseResults.push(result);
110
+ },
111
+ enqueueRewardResult(result) {
112
+ rewardResults.push(result);
113
+ },
114
+ get purchaseInputs() {
115
+ return [...purchaseInputs];
116
+ },
117
+ get rewardInputs() {
118
+ return [...rewardInputs];
119
+ },
120
+ };
121
+ }
122
+ async function runCallbackOnlyFailClosedScenario(createVerifier, now) {
123
+ const context = createScenarioContext(createVerifier, now);
124
+ const purchase = await context.backend.purchases.verifyPurchase(purchaseRequest());
125
+ const reward = await context.backend.adRewards.claimAdReward(rewardRequest());
126
+ const postSuccessPurchase = await context.backend.purchases.verifyPurchase(purchaseRequest({
127
+ idempotencyKey: 'purchase-post-success',
128
+ evidence: {
129
+ schema: appsInTossPurchaseCallbackEvidenceSchema,
130
+ payload: {
131
+ orderId: 'ait-order-1',
132
+ sku: 'ait.conformance.coins',
133
+ source: 'success-event',
134
+ },
135
+ },
136
+ }));
137
+ assertEqual(purchase.verified, false, 'callback-only purchase must not grant');
138
+ assertEqual(purchase.reason, 'AIT_PURCHASE_AUTHORITY_UNAVAILABLE', 'callback-only purchase must identify the missing authority');
139
+ assertEqual(reward.granted, false, 'callback-only reward must not grant');
140
+ assertEqual(reward.reason, 'AIT_REWARD_AUTHORITY_UNAVAILABLE', 'callback-only reward must identify the missing authority');
141
+ assertEqual(postSuccessPurchase.reason, 'AIT_PURCHASE_CALLBACK_SOURCE_INVALID', 'post-success purchase evidence must never be grantable');
142
+ await assertEntitlementCount(context.store, 0, 'callback-only evidence');
143
+ }
144
+ async function runPurchaseAuthorityRetryScenario(createVerifier, now) {
145
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
146
+ fixture.enqueuePurchaseResult(resolvedOrder());
147
+ const context = createScenarioContext(createVerifier, now, {
148
+ purchaseAuthority: fixture.purchaseAuthority,
149
+ });
150
+ const request = purchaseRequest();
151
+ const first = await context.backend.purchases.verifyPurchase(request);
152
+ const retry = await context.backend.purchases.verifyPurchase(request);
153
+ assertEqual(first.verified, true, 'authority-confirmed purchase must grant');
154
+ assertEqual(first.alreadyProcessed, false, 'first purchase must be new');
155
+ assertEqual(retry.verified, true, 'same purchase retry must remain accepted');
156
+ assertEqual(retry.alreadyProcessed, true, 'same purchase retry must deduplicate');
157
+ assertEqual(fixture.purchaseInputs.length, 1, 'ledger retry must not re-query authority');
158
+ await assertEntitlementCount(context.store, 1, 'purchase retry');
159
+ const [transaction] = await context.store.listEntitlementTransactions();
160
+ assertEqual(transaction?.evidenceVerificationId, 'apps-in-toss:purchase:ait-order-1', 'purchase must persist the stable authority identity');
161
+ }
162
+ async function runPurchaseProductGrantCallbackScenario(createVerifier, now) {
163
+ const failClosedStore = new AbortAwareConformanceStore();
164
+ const failClosedContext = createScenarioContext(createVerifier, now, {}, failClosedStore);
165
+ const backendFailures = [];
166
+ const failClosedCallback = createAppsInTossProductGrantCallback({
167
+ purchaseVerification: createConformanceProductGrantVerificationPort((request, signal) => failClosedStore.runWithGrantSignal(signal, () => failClosedContext.backend.purchases.verifyPurchase(request))),
168
+ playerId: 'ait-player-1',
169
+ productId: 'CONFORMANCE_COINS',
170
+ platformSku: 'ait.conformance.coins',
171
+ onVerificationError: (error) => backendFailures.push(error),
172
+ now: () => now,
173
+ });
174
+ assertEqual(await failClosedCallback({ orderId: 'ait-order-fail-closed' }), false, 'product-grant callback must fail closed without authority');
175
+ assertEqual(backendFailures.length, 1, 'backend rejection must be observable');
176
+ assertEqual(backendFailures[0] instanceof Error
177
+ && backendFailures[0].message.includes('AIT_PURCHASE_AUTHORITY_UNAVAILABLE'), true, 'backend rejection diagnostics must retain the server reason');
178
+ const transportFailures = [];
179
+ const transportFailureCallback = createAppsInTossProductGrantCallback({
180
+ purchaseVerification: createAppsInTossProductGrantVerificationPort(async () => {
181
+ throw new Error('simulated callback transport failure');
182
+ }),
183
+ playerId: 'ait-player-1',
184
+ productId: 'CONFORMANCE_COINS',
185
+ platformSku: 'ait.conformance.coins',
186
+ onVerificationError: (error) => transportFailures.push(error),
187
+ now: () => now,
188
+ });
189
+ assertEqual(await transportFailureCallback({ orderId: 'ait-order-transport-failure' }), false, 'product-grant transport failure must return false to the SDK');
190
+ assertEqual(transportFailures.length, 1, 'product-grant failures must be observable');
191
+ const deadlineStore = new AbortAwareConformanceStore();
192
+ const deadlineSignals = [];
193
+ const deadlineAuthorityGate = createVoidDeferred();
194
+ const deadlineAuthorityStarted = createVoidDeferred();
195
+ const lateVerificationSettled = createVoidDeferred();
196
+ const deadlineContext = createScenarioContext(createVerifier, now, {
197
+ purchaseAuthority: {
198
+ async getOrderStatus() {
199
+ deadlineAuthorityStarted.resolve();
200
+ await deadlineAuthorityGate.promise;
201
+ return resolvedOrder({ orderId: 'ait-order-deadline' });
202
+ },
203
+ },
204
+ }, deadlineStore);
205
+ const deadlineCallback = createAppsInTossProductGrantCallback({
206
+ purchaseVerification: createConformanceProductGrantVerificationPort(async (request, signal) => {
207
+ deadlineSignals.push(signal);
208
+ try {
209
+ return await deadlineStore.runWithGrantSignal(signal, () => deadlineContext.backend.purchases.verifyPurchase(request));
210
+ }
211
+ finally {
212
+ lateVerificationSettled.resolve();
213
+ }
214
+ }),
215
+ playerId: 'ait-player-1',
216
+ productId: 'CONFORMANCE_COINS',
217
+ platformSku: 'ait.conformance.coins',
218
+ timeoutMs: 1,
219
+ now: () => now,
220
+ });
221
+ const deadlineResult = deadlineCallback({ orderId: 'ait-order-deadline' });
222
+ await deadlineAuthorityStarted.promise;
223
+ assertEqual(await deadlineResult, false, 'product-grant deadline must return false to the SDK');
224
+ assertEqual(deadlineSignals[0]?.aborted, true, 'product-grant deadline must abort transport');
225
+ deadlineAuthorityGate.resolve();
226
+ await lateVerificationSettled.promise;
227
+ await assertEntitlementCount(deadlineStore, 0, 'aborted late product grant');
228
+ await assertEntitlementCount(failClosedContext.store, 0, 'fail-closed product-grant callback');
229
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
230
+ fixture.enqueuePurchaseResult(resolvedOrder());
231
+ const store = new AbortAwareConformanceStore();
232
+ const context = createScenarioContext(createVerifier, now, { purchaseAuthority: fixture.purchaseAuthority }, store);
233
+ const callback = createAppsInTossProductGrantCallback({
234
+ purchaseVerification: createConformanceProductGrantVerificationPort((request, signal) => store.runWithGrantSignal(signal, () => context.backend.purchases.verifyPurchase(request))),
235
+ playerId: 'ait-player-1',
236
+ productId: 'CONFORMANCE_COINS',
237
+ platformSku: 'ait.conformance.coins',
238
+ now: () => now,
239
+ });
240
+ assertEqual(await callback({ orderId: 'ait-order-1' }), true, 'product-grant callback must wait for an authoritative ledger grant');
241
+ await assertEntitlementCount(context.store, 1, 'authoritative product-grant callback');
242
+ const [transaction] = await context.store.listEntitlementTransactions();
243
+ assertEqual(transaction?.idempotencyKey, 'apps-in-toss:purchase:ait-order-1', 'product-grant callback must derive restart-stable order idempotency');
244
+ }
245
+ async function runPurchasePendingOrderRestoreScenario(createVerifier, now) {
246
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
247
+ fixture.enqueuePurchaseResult(new Error('simulated partner-server failure'));
248
+ fixture.enqueuePurchaseResult(resolvedOrder({ status: 'PAYMENT_COMPLETED' }));
249
+ const context = createScenarioContext(createVerifier, now, {
250
+ purchaseAuthority: fixture.purchaseAuthority,
251
+ });
252
+ const initial = await context.backend.purchases.verifyPurchase(purchaseRequest());
253
+ const restored = await context.backend.purchases.verifyPurchase(purchaseRequest({
254
+ evidence: createAppsInTossPurchaseCallbackEvidence({
255
+ orderId: 'ait-order-1',
256
+ platformSku: 'ait.conformance.coins',
257
+ source: 'pending-order-restore',
258
+ }),
259
+ }));
260
+ assertEqual(initial.verified, false, 'server failure must not grant');
261
+ assertEqual(initial.reason, 'AIT_PURCHASE_AUTHORITY_ERROR', 'server failure must surface as a purchase authority failure');
262
+ assertEqual(restored.verified, true, 'restored payment-completed order must grant');
263
+ assertEqual(restored.alreadyProcessed, false, 'restored order must create one grant');
264
+ await assertEntitlementCount(context.store, 1, 'pending order restore');
265
+ const [transaction] = await context.store.listEntitlementTransactions();
266
+ assertEqual(transaction?.payload.appsInTossOrderStatus, 'PAYMENT_COMPLETED', 'restore must retain the authoritative order status');
267
+ assertEqual(transaction?.payload.appsInTossGrantCompletionRequired, true, 'payment-completed recovery must require SDK grant completion');
268
+ }
269
+ async function runPurchaseAuthorityMatchingScenario(createVerifier, now) {
270
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
271
+ fixture.enqueuePurchaseResult(resolvedOrder({ orderId: 'different-order' }));
272
+ fixture.enqueuePurchaseResult(resolvedOrder({ playerId: 'different-player' }));
273
+ fixture.enqueuePurchaseResult(resolvedOrder({
274
+ orderId: 'ait-order-sku',
275
+ sku: 'different-sku',
276
+ }));
277
+ fixture.enqueuePurchaseResult(resolvedOrder({
278
+ orderId: 'ait-order-refunded',
279
+ status: 'REFUNDED',
280
+ }));
281
+ fixture.enqueuePurchaseResult(resolvedOrder({
282
+ orderId: 'ait-order-progress',
283
+ status: 'ORDER_IN_PROGRESS',
284
+ }));
285
+ const context = createScenarioContext(createVerifier, now, {
286
+ purchaseAuthority: fixture.purchaseAuthority,
287
+ });
288
+ const orderMismatch = await context.backend.purchases.verifyPurchase(purchaseRequest());
289
+ const playerMismatch = await context.backend.purchases.verifyPurchase(purchaseRequest({ idempotencyKey: 'purchase-player' }));
290
+ const skuMismatch = await context.backend.purchases.verifyPurchase(purchaseRequest({
291
+ platformTransactionId: 'ait-order-sku',
292
+ idempotencyKey: 'purchase-sku',
293
+ evidence: purchaseEvidence('ait-order-sku'),
294
+ }));
295
+ const refunded = await context.backend.purchases.verifyPurchase(purchaseRequest({
296
+ platformTransactionId: 'ait-order-refunded',
297
+ idempotencyKey: 'purchase-refunded',
298
+ evidence: purchaseEvidence('ait-order-refunded'),
299
+ }));
300
+ const progress = await context.backend.purchases.verifyPurchase(purchaseRequest({
301
+ platformTransactionId: 'ait-order-progress',
302
+ idempotencyKey: 'purchase-progress',
303
+ evidence: purchaseEvidence('ait-order-progress'),
304
+ }));
305
+ assertEqual(orderMismatch.reason, 'AIT_PURCHASE_AUTHORITY_ORDER_ID_MISMATCH', 'purchase order identifier mismatch');
306
+ assertEqual(playerMismatch.reason, 'AIT_PURCHASE_AUTHORITY_PLAYER_MISMATCH', 'purchase player mismatch');
307
+ assertEqual(skuMismatch.reason, 'AIT_PURCHASE_AUTHORITY_SKU_MISMATCH', 'purchase sku mismatch');
308
+ assertEqual(refunded.reason, 'AIT_PURCHASE_ORDER_REFUNDED', 'refunded purchase');
309
+ assertEqual(progress.reason, 'AIT_PURCHASE_ORDER_ORDER_IN_PROGRESS', 'in-progress purchase');
310
+ await assertEntitlementCount(context.store, 0, 'purchase mismatches and status');
311
+ }
312
+ async function runPurchaseTimestampNormalizationScenario(createVerifier, now) {
313
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
314
+ fixture.enqueuePurchaseResult(resolvedOrder({
315
+ orderId: 'ait-order-kst',
316
+ statusDeterminedAt: '2025-09-12T16:57:12',
317
+ }));
318
+ fixture.enqueuePurchaseResult(resolvedOrder({
319
+ orderId: 'ait-order-invalid-date',
320
+ statusDeterminedAt: '2025-02-30T16:57:12',
321
+ }));
322
+ const context = createScenarioContext(createVerifier, now, {
323
+ purchaseAuthority: fixture.purchaseAuthority,
324
+ });
325
+ const kstOrder = await context.backend.purchases.verifyPurchase(purchaseRequest({
326
+ platformTransactionId: 'ait-order-kst',
327
+ idempotencyKey: 'purchase-kst',
328
+ evidence: purchaseEvidence('ait-order-kst'),
329
+ }));
330
+ const invalidDate = await context.backend.purchases.verifyPurchase(purchaseRequest({
331
+ platformTransactionId: 'ait-order-invalid-date',
332
+ idempotencyKey: 'purchase-invalid-date',
333
+ evidence: purchaseEvidence('ait-order-invalid-date'),
334
+ }));
335
+ assertEqual(kstOrder.verified, true, 'documented KST timestamp must verify deterministically');
336
+ assertEqual(invalidDate.reason, 'AIT_PURCHASE_AUTHORITY_TIMESTAMP_INVALID', 'calendar-overflow timestamp must be rejected');
337
+ const [transaction] = await context.store.listEntitlementTransactions();
338
+ assertEqual(transaction?.payload.evidenceVerifiedAt, '2025-09-12T07:57:12.000Z', 'offset-free order timestamps must normalize as documented KST');
339
+ await assertEntitlementCount(context.store, 1, 'deterministic timestamp normalization');
340
+ }
341
+ async function runRewardAuthorityRetryAndReplayScenario(createVerifier, now) {
342
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
343
+ fixture.enqueueRewardResult(verifiedReward());
344
+ fixture.enqueueRewardResult(verifiedReward());
345
+ const context = createScenarioContext(createVerifier, now, {
346
+ rewardAuthority: fixture.rewardAuthority,
347
+ });
348
+ const request = rewardRequest();
349
+ const first = await context.backend.adRewards.claimAdReward(request);
350
+ const retry = await context.backend.adRewards.claimAdReward(request);
351
+ const replay = await context.backend.adRewards.claimAdReward({
352
+ ...request,
353
+ idempotencyKey: 'reward-replay',
354
+ });
355
+ assertEqual(first.granted, true, 'authority-confirmed reward must grant');
356
+ assertEqual(first.alreadyProcessed, false, 'first reward must be new');
357
+ assertEqual(retry.granted, true, 'same reward retry must remain accepted');
358
+ assertEqual(retry.alreadyProcessed, true, 'same reward retry must deduplicate');
359
+ assertEqual(replay.granted, false, 'replayed authority evidence must not grant');
360
+ assertEqual(replay.reason, 'EVIDENCE_ALREADY_PROCESSED', 'replayed authority evidence must be rejected explicitly');
361
+ assertEqual(fixture.rewardInputs.length, 2, 'retry must skip authority, replay must query it');
362
+ await assertEntitlementCount(context.store, 1, 'reward retry and replay');
363
+ }
364
+ async function runRewardTimestampValidationScenario(createVerifier, now) {
365
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
366
+ fixture.enqueueRewardResult(verifiedReward({
367
+ authorityEventId: 'ait-authority-event-offset-free',
368
+ correlationId: 'ait-correlation-offset-free',
369
+ verifiedAt: '2030-01-02T03:02:00',
370
+ }));
371
+ fixture.enqueueRewardResult(verifiedReward({
372
+ authorityEventId: 'ait-authority-event-offset',
373
+ correlationId: 'ait-correlation-offset',
374
+ verifiedAt: '2030-01-02T12:02:00+09:00',
375
+ }));
376
+ const context = createScenarioContext(createVerifier, now, {
377
+ rewardAuthority: fixture.rewardAuthority,
378
+ });
379
+ const offsetFree = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-offset-free', {
380
+ idempotencyKey: 'reward-offset-free',
381
+ }));
382
+ const explicitOffset = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-offset', {
383
+ idempotencyKey: 'reward-explicit-offset',
384
+ }));
385
+ assertEqual(offsetFree.reason, 'AIT_REWARD_AUTHORITY_TIMESTAMP_INVALID', 'reward authority timestamps without a zone must fail closed');
386
+ assertEqual(explicitOffset.granted, true, 'explicit-offset reward timestamp must grant');
387
+ const [transaction] = await context.store.listEntitlementTransactions();
388
+ assertEqual(transaction?.payload.evidenceVerifiedAt, '2030-01-02T03:02:00.000Z', 'explicit-offset reward timestamps must normalize without KST fallback');
389
+ await assertEntitlementCount(context.store, 1, 'reward timestamp validation');
390
+ }
391
+ async function runAuthorityErrorsAndRewardMatchingScenario(createVerifier, now) {
392
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
393
+ const diagnosticReason = `Authority temporarily unavailable: ${'x'.repeat(512)} `;
394
+ const oversizedDiagnosticReason = 'x'.repeat(4097);
395
+ fixture.enqueuePurchaseResult({ decision: 'rejected', reason: diagnosticReason });
396
+ fixture.enqueuePurchaseResult({ decision: 'pending', reason: diagnosticReason });
397
+ fixture.enqueuePurchaseResult({ decision: 'pending', reason: oversizedDiagnosticReason });
398
+ fixture.enqueueRewardResult(new Error('simulated reward authority failure'));
399
+ fixture.enqueueRewardResult(verifiedReward({
400
+ correlationId: 'different-correlation',
401
+ }));
402
+ fixture.enqueueRewardResult(verifiedReward({
403
+ correlationId: 'ait-correlation-player',
404
+ playerId: 'different-player',
405
+ }));
406
+ fixture.enqueueRewardResult(verifiedReward({
407
+ correlationId: 'ait-correlation-placement',
408
+ platformPlacementId: 'different-placement',
409
+ }));
410
+ fixture.enqueueRewardResult({ decision: 'pending' });
411
+ fixture.enqueueRewardResult({ decision: 'rejected', reason: diagnosticReason });
412
+ fixture.enqueueRewardResult({ decision: 'pending', reason: diagnosticReason });
413
+ fixture.enqueueRewardResult({ decision: 'rejected', reason: oversizedDiagnosticReason });
414
+ const context = createScenarioContext(createVerifier, now, {
415
+ purchaseAuthority: fixture.purchaseAuthority,
416
+ rewardAuthority: fixture.rewardAuthority,
417
+ });
418
+ const purchaseRejected = await context.backend.purchases.verifyPurchase(purchaseRequest({ idempotencyKey: 'purchase-free-form-rejected' }));
419
+ const purchasePending = await context.backend.purchases.verifyPurchase(purchaseRequest({ idempotencyKey: 'purchase-free-form-pending' }));
420
+ const purchaseOversizedReason = await context.backend.purchases.verifyPurchase(purchaseRequest({ idempotencyKey: 'purchase-oversized-reason' }));
421
+ const requestCorrelationMismatch = await context.backend.adRewards.claimAdReward(rewardRequest({
422
+ idempotencyKey: 'reward-request-correlation',
423
+ platformImpressionId: 'different-request-correlation',
424
+ }));
425
+ // Build this request directly so platformImpressionId is absent, not present with undefined.
426
+ const requestCorrelationMissingRequest = {
427
+ target: 'ait',
428
+ playerId: 'ait-player-1',
429
+ placementId: 'CONFORMANCE_REWARD',
430
+ idempotencyKey: 'reward-request-correlation-missing',
431
+ completedAt: '2030-01-02T03:01:00.000Z',
432
+ evidence: rewardEvidence('ait-correlation-1'),
433
+ };
434
+ const requestCorrelationMissing = await context.backend.adRewards.claimAdReward(requestCorrelationMissingRequest);
435
+ const failed = await context.backend.adRewards.claimAdReward(rewardRequest());
436
+ const correlationMismatch = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-mismatch', {
437
+ idempotencyKey: 'reward-correlation',
438
+ }));
439
+ const playerMismatch = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-player', {
440
+ idempotencyKey: 'reward-player',
441
+ }));
442
+ const placementMismatch = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-placement', {
443
+ idempotencyKey: 'reward-placement',
444
+ }));
445
+ const pending = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-pending', {
446
+ idempotencyKey: 'reward-pending',
447
+ }));
448
+ const rewardRejected = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-free-form-rejected', {
449
+ idempotencyKey: 'reward-free-form-rejected',
450
+ }));
451
+ const rewardPending = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-free-form-pending', {
452
+ idempotencyKey: 'reward-free-form-pending',
453
+ }));
454
+ const rewardOversizedReason = await context.backend.adRewards.claimAdReward(rewardRequestForCorrelation('ait-correlation-oversized-reason', {
455
+ idempotencyKey: 'reward-oversized-reason',
456
+ }));
457
+ assertEqual(purchaseRejected.reason, diagnosticReason, 'purchase rejection reason forwarding');
458
+ assertEqual(purchasePending.reason, diagnosticReason, 'purchase pending reason forwarding');
459
+ assertEqual(purchaseOversizedReason.reason, 'AIT_PURCHASE_AUTHORITY_ERROR', 'oversized purchase authority reason must fail closed');
460
+ assertEqual(failed.reason, 'AIT_REWARD_AUTHORITY_ERROR', 'reward authority error');
461
+ assertEqual(requestCorrelationMismatch.reason, 'AIT_REWARD_CORRELATION_ID_MISMATCH', 'reward request correlation mismatch');
462
+ assertEqual(requestCorrelationMissing.reason, 'AIT_REWARD_REQUEST_CORRELATION_ID_REQUIRED', 'reward request correlation required');
463
+ assertEqual(correlationMismatch.reason, 'AIT_REWARD_AUTHORITY_CORRELATION_ID_MISMATCH', 'reward correlation mismatch');
464
+ assertEqual(playerMismatch.reason, 'AIT_REWARD_AUTHORITY_PLAYER_MISMATCH', 'reward player mismatch');
465
+ assertEqual(placementMismatch.reason, 'AIT_REWARD_AUTHORITY_PLACEMENT_MISMATCH', 'reward placement mismatch');
466
+ assertEqual(pending.reason, 'AIT_REWARD_AUTHORITY_PENDING', 'reward pending decision');
467
+ assertEqual(rewardRejected.reason, diagnosticReason, 'reward rejection reason forwarding');
468
+ assertEqual(rewardPending.reason, diagnosticReason, 'reward pending reason forwarding');
469
+ assertEqual(rewardOversizedReason.reason, 'AIT_REWARD_AUTHORITY_ERROR', 'oversized reward authority reason must fail closed');
470
+ assertEqual(fixture.rewardInputs.length, 8, 'request-level correlation failures must not call the reward authority');
471
+ await assertEntitlementCount(context.store, 0, 'reward errors and mismatches');
472
+ }
473
+ function createScenarioContext(createVerifier, now, authorities = {}, store = createInMemoryGameServicesStore()) {
474
+ return {
475
+ backend: createGameServicesBackend({
476
+ catalog,
477
+ placements,
478
+ store,
479
+ now: () => now,
480
+ evidenceVerifier: createVerifier(authorities),
481
+ }),
482
+ store,
483
+ };
484
+ }
485
+ function purchaseRequest(overrides = {}) {
486
+ return {
487
+ target: 'ait',
488
+ playerId: 'ait-player-1',
489
+ productId: 'CONFORMANCE_COINS',
490
+ platformTransactionId: 'ait-order-1',
491
+ idempotencyKey: 'purchase-1',
492
+ purchasedAt: '2030-01-02T03:00:00.000Z',
493
+ evidence: purchaseEvidence('ait-order-1'),
494
+ ...overrides,
495
+ };
496
+ }
497
+ function rewardRequest(overrides = {}) {
498
+ return {
499
+ target: 'ait',
500
+ playerId: 'ait-player-1',
501
+ placementId: 'CONFORMANCE_REWARD',
502
+ platformImpressionId: 'ait-correlation-1',
503
+ idempotencyKey: 'reward-1',
504
+ completedAt: '2030-01-02T03:01:00.000Z',
505
+ evidence: rewardEvidence('ait-correlation-1'),
506
+ ...overrides,
507
+ };
508
+ }
509
+ function rewardRequestForCorrelation(correlationId, overrides = {}) {
510
+ return rewardRequest({
511
+ platformImpressionId: correlationId,
512
+ evidence: rewardEvidence(correlationId),
513
+ ...overrides,
514
+ });
515
+ }
516
+ function purchaseEvidence(orderId) {
517
+ return createAppsInTossPurchaseCallbackEvidence({
518
+ orderId,
519
+ platformSku: 'ait.conformance.coins',
520
+ source: 'process-product-grant',
521
+ });
522
+ }
523
+ function rewardEvidence(correlationId) {
524
+ return createAppsInTossRewardCallbackEvidence({
525
+ correlationId,
526
+ platformPlacementId: 'ait.conformance.reward',
527
+ });
528
+ }
529
+ function resolvedOrder(overrides = {}) {
530
+ return {
531
+ decision: 'resolved',
532
+ order: {
533
+ orderId: 'ait-order-1',
534
+ playerId: 'ait-player-1',
535
+ sku: 'ait.conformance.coins',
536
+ status: 'PURCHASED',
537
+ statusDeterminedAt: '2030-01-02T03:02:00+09:00',
538
+ ...overrides,
539
+ },
540
+ };
541
+ }
542
+ function verifiedReward(overrides = {}) {
543
+ return {
544
+ decision: 'verified',
545
+ authorityEventId: 'ait-authority-event-1',
546
+ correlationId: 'ait-correlation-1',
547
+ playerId: 'ait-player-1',
548
+ platformPlacementId: 'ait.conformance.reward',
549
+ verifiedAt: '2030-01-02T03:02:00.000Z',
550
+ ...overrides,
551
+ };
552
+ }
553
+ function createConformanceProductGrantVerificationPort(verifyPurchase) {
554
+ return createAppsInTossProductGrantVerificationPort(async ({ request, signal }) => {
555
+ if (signal.aborted) {
556
+ throw signal.reason;
557
+ }
558
+ const response = await verifyPurchase(request, signal);
559
+ if (signal.aborted) {
560
+ throw signal.reason;
561
+ }
562
+ return response;
563
+ });
564
+ }
565
+ class AbortAwareConformanceStore extends InMemoryGameServicesStore {
566
+ grantSignal;
567
+ async runWithGrantSignal(signal, operation) {
568
+ if (this.grantSignal !== undefined) {
569
+ throw new Error('Concurrent conformance grants are not supported.');
570
+ }
571
+ this.grantSignal = signal;
572
+ try {
573
+ return await operation();
574
+ }
575
+ finally {
576
+ this.grantSignal = undefined;
577
+ }
578
+ }
579
+ async recordEntitlementGrant(input) {
580
+ if (this.grantSignal === undefined) {
581
+ throw new Error('Conformance grant signal is required.');
582
+ }
583
+ if (this.grantSignal.aborted) {
584
+ throw this.grantSignal.reason ?? new Error('Conformance grant was aborted.');
585
+ }
586
+ return super.recordEntitlementGrant(input);
587
+ }
588
+ }
589
+ function shiftFixtureResult(queue, exhaustedResult) {
590
+ const result = queue.shift();
591
+ if (result === undefined) {
592
+ return exhaustedResult;
593
+ }
594
+ if (result instanceof Error) {
595
+ throw result;
596
+ }
597
+ return result;
598
+ }
599
+ async function assertEntitlementCount(store, expected, label) {
600
+ assertEqual((await store.listEntitlementTransactions()).length, expected, `${label} ledger count`);
601
+ }
602
+ function createVoidDeferred() {
603
+ let resolve;
604
+ const promise = new Promise((promiseResolve) => {
605
+ resolve = promiseResolve;
606
+ });
607
+ return { promise, resolve };
608
+ }
609
+ function assertEqual(actual, expected, label) {
610
+ if (actual !== expected) {
611
+ throw new Error(`${label}: expected ${String(expected)}, received ${String(actual)}.`);
612
+ }
613
+ }