@mpgd/platform 0.19.0 → 0.21.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.
- package/dist/commerce-conformance.d.ts +160 -53
- package/dist/commerce-conformance.js +550 -485
- package/dist/commerce-contract.d.ts +222 -0
- package/dist/commerce-contract.js +1 -0
- package/dist/commerce-vectors.d.ts +8 -0
- package/dist/commerce-vectors.js +38 -33
- package/dist/commerce-wallet.d.ts +324 -0
- package/dist/commerce-wallet.js +2087 -0
- package/dist/commerce.d.ts +30 -146
- package/dist/commerce.js +260 -235
- package/dist/index.d.ts +8 -0
- package/package.json +6 -2
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createCommerceEventBuffer, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, } from './commerce.js';
|
|
2
|
+
import { createCreditCommerceProvider, createInMemoryCreditWallet, normalizeIsoTimestamp, } from './commerce-wallet.js';
|
|
3
|
+
// The wallet rules moved to `./commerce-wallet` with B7; they stay importable here.
|
|
4
|
+
export { addIsoDuration, compareCreditLotSpendOrder, deriveCreditLotSpendScope, normalizeIsoTimestamp, } from './commerce-wallet.js';
|
|
2
5
|
/**
|
|
3
6
|
* Commerce v2 conformance runner (RFC "Conformance vectors") with the in-memory
|
|
4
7
|
* reference pieces every vector needs: a fake clock, a fake store with its
|
|
@@ -10,7 +13,11 @@ import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, Comme
|
|
|
10
13
|
*
|
|
11
14
|
* Store (B-phase) providers run the same vectors by supplying
|
|
12
15
|
* `createProvider` and translating the store actor steps into their own fake
|
|
13
|
-
* SDK; the reconciler, journal, backend, and matcher stay shared.
|
|
16
|
+
* SDK; the reconciler, journal, backend, and matcher stay shared. A vector
|
|
17
|
+
* flagged `profile.adaptive` then matches expectations derived from the
|
|
18
|
+
* provider's declared capabilities rather than its fixture profile; see
|
|
19
|
+
* `deriveAdaptiveCommerceConformanceExpectation` and
|
|
20
|
+
* `commerceConformanceAdaptiveSkipReason`.
|
|
14
21
|
*/
|
|
15
22
|
export const commerceConformanceDefaultPlayerId = 'player-1';
|
|
16
23
|
export const commerceConformanceDefaultTitleId = 'title-a';
|
|
@@ -28,31 +35,6 @@ export function createCommerceConformanceClock(initial = commerceConformanceDefa
|
|
|
28
35
|
},
|
|
29
36
|
};
|
|
30
37
|
}
|
|
31
|
-
/** Canonical `YYYY-MM-DDTHH:mm:ssZ`; milliseconds are kept only when non-zero. */
|
|
32
|
-
export function normalizeIsoTimestamp(input) {
|
|
33
|
-
const millis = Date.parse(input);
|
|
34
|
-
if (Number.isNaN(millis)) {
|
|
35
|
-
throw new TypeError(`Invalid ISO-8601 timestamp: ${input}`);
|
|
36
|
-
}
|
|
37
|
-
return formatIsoTimestamp(millis);
|
|
38
|
-
}
|
|
39
|
-
function formatIsoTimestamp(millis) {
|
|
40
|
-
const iso = new Date(millis).toISOString();
|
|
41
|
-
return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
|
|
42
|
-
}
|
|
43
|
-
/** Adds an ISO-8601 duration (`P30D`, `P1M`, `PT12H`, ...) to a timestamp, in UTC calendar units. */
|
|
44
|
-
export function addIsoDuration(at, duration) {
|
|
45
|
-
const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u
|
|
46
|
-
.exec(duration);
|
|
47
|
-
if (match === null || duration === 'P' || duration === 'PT') {
|
|
48
|
-
throw new TypeError(`Invalid ISO-8601 duration: ${duration}`);
|
|
49
|
-
}
|
|
50
|
-
const [, years, months, weeks, days, hours, minutes, seconds] = match;
|
|
51
|
-
const date = new Date(normalizeIsoTimestamp(at));
|
|
52
|
-
date.setUTCFullYear(date.getUTCFullYear() + Number(years ?? 0), date.getUTCMonth() + Number(months ?? 0), date.getUTCDate() + Number(weeks ?? 0) * 7 + Number(days ?? 0));
|
|
53
|
-
date.setUTCHours(date.getUTCHours() + Number(hours ?? 0), date.getUTCMinutes() + Number(minutes ?? 0), date.getUTCSeconds() + Number(seconds ?? 0));
|
|
54
|
-
return formatIsoTimestamp(date.getTime());
|
|
55
|
-
}
|
|
56
38
|
// Capabilities and fixture filling.
|
|
57
39
|
/** Capabilities of a vector profile: omitted blocks default to all supported and `available`. */
|
|
58
40
|
export function createCommerceConformanceCapabilities(profile) {
|
|
@@ -137,6 +119,10 @@ export function createCommerceConformanceStore(input) {
|
|
|
137
119
|
let purchaseUiOpened = 0;
|
|
138
120
|
let restoreCalls = 0;
|
|
139
121
|
const emit = (event) => {
|
|
122
|
+
// An event-less store reports nothing; the reconciler polls its available list instead.
|
|
123
|
+
if (!capabilities.lifecycle.events) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
140
126
|
if (listener === undefined) {
|
|
141
127
|
buffer.push(event);
|
|
142
128
|
return;
|
|
@@ -283,12 +269,16 @@ function matchesFetchProducts(entry, request) {
|
|
|
283
269
|
* Fake backend: every verify call consumes the next `queueVerify` decision in
|
|
284
270
|
* order and is logged; an unqueued verify is an unscripted operation. A
|
|
285
271
|
* `granted` decision makes sure the ledger holds the entry, so a replayed
|
|
286
|
-
* grant and a host-pushed grant never create a second entry.
|
|
272
|
+
* grant and a host-pushed grant never create a second entry. With
|
|
273
|
+
* `finalizeOnReread`, a replayed granted key is answered as finalized from
|
|
274
|
+
* the backend's own state.
|
|
287
275
|
*/
|
|
288
276
|
export function createCommerceConformanceBackend(input = {}) {
|
|
289
277
|
const decisions = [];
|
|
290
278
|
const verifyRequests = [];
|
|
291
279
|
const ledger = [];
|
|
280
|
+
/** Ledger entry id per granted idempotency key. */
|
|
281
|
+
const grants = new Map();
|
|
292
282
|
const findEntry = (entryId) => ledger.find((entry) => entry.entryId === entryId);
|
|
293
283
|
const reversalOf = (ledgerEntryId) => ledger.find((entry) => entry.type === 'revoke' && entry.reverses === ledgerEntryId);
|
|
294
284
|
return {
|
|
@@ -303,6 +293,26 @@ export function createCommerceConformanceBackend(input = {}) {
|
|
|
303
293
|
},
|
|
304
294
|
async verifyPurchase(request) {
|
|
305
295
|
verifyRequests.push(request);
|
|
296
|
+
const granted = grants.get(request.idempotencyKey);
|
|
297
|
+
if (granted !== undefined
|
|
298
|
+
&& reversalOf(granted) === undefined
|
|
299
|
+
&& input.finalizeOnReread?.() === true) {
|
|
300
|
+
// The key's own grant answers the re-read: no queued decision is
|
|
301
|
+
// consumed, so a decision scripted for another record stays in place.
|
|
302
|
+
// A reversed grant is not finalized; the re-read falls through to the script.
|
|
303
|
+
await input.finalize?.({
|
|
304
|
+
store: request.store,
|
|
305
|
+
transactionId: request.transactionId,
|
|
306
|
+
ledgerEntryId: granted,
|
|
307
|
+
logicalId: request.logicalId,
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
status: 'granted',
|
|
311
|
+
ledgerEntryId: granted,
|
|
312
|
+
finalization: 'completed',
|
|
313
|
+
alreadyProcessed: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
306
316
|
const decision = decisions.shift();
|
|
307
317
|
if (decision === undefined) {
|
|
308
318
|
throw new CommerceConformanceScriptError(`Unscripted verify for ${request.idempotencyKey} (${request.store}/${request.transactionId}); `
|
|
@@ -325,6 +335,7 @@ export function createCommerceConformanceBackend(input = {}) {
|
|
|
325
335
|
idempotencyKey: request.idempotencyKey,
|
|
326
336
|
});
|
|
327
337
|
}
|
|
338
|
+
grants.set(request.idempotencyKey, ledgerEntryId);
|
|
328
339
|
if (decision.finalization === 'completed') {
|
|
329
340
|
await input.finalize?.({
|
|
330
341
|
store: request.store,
|
|
@@ -726,6 +737,14 @@ export function createCommerceConformanceRuntime(input) {
|
|
|
726
737
|
};
|
|
727
738
|
const pass = async () => {
|
|
728
739
|
const capabilities = await provider.getCapabilities();
|
|
740
|
+
// A provider without lifecycle.events is polled: every pass reads its available purchases.
|
|
741
|
+
if (!capabilities.lifecycle.events && capabilities.lifecycle.availablePurchases) {
|
|
742
|
+
for (const purchase of await provider.getAvailablePurchases({
|
|
743
|
+
includePending: capabilities.lifecycle.pending,
|
|
744
|
+
})) {
|
|
745
|
+
inbox.push({ type: 'purchaseUpdated', purchase });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
729
748
|
for (const event of inbox.splice(0, inbox.length)) {
|
|
730
749
|
if (event.type === 'purchaseUpdated') {
|
|
731
750
|
await ingestPurchase(event.purchase);
|
|
@@ -787,7 +806,8 @@ export function createCommerceConformanceRuntime(input) {
|
|
|
787
806
|
// Subscribe first, then read: an event delivered before the listener is found either way.
|
|
788
807
|
unsubscribe = provider.subscribe(listener);
|
|
789
808
|
const capabilities = await provider.getCapabilities();
|
|
790
|
-
|
|
809
|
+
// An event-less provider is polled by the first pass instead, so it is read once.
|
|
810
|
+
if (capabilities.lifecycle.availablePurchases && capabilities.lifecycle.events) {
|
|
791
811
|
for (const purchase of await provider.getAvailablePurchases({
|
|
792
812
|
includePending: capabilities.lifecycle.pending,
|
|
793
813
|
})) {
|
|
@@ -932,361 +952,76 @@ export function createCommerceConformanceRuntime(input) {
|
|
|
932
952
|
};
|
|
933
953
|
}
|
|
934
954
|
/**
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
* `INSUFFICIENT_BALANCE`; memberships debit `membership:<id>:<period>` per
|
|
940
|
-
* paid period and lapse at period end when the eligible balance runs out.
|
|
955
|
+
* The reference wallet (`createInMemoryCreditWallet`) on the vector clock,
|
|
956
|
+
* with the actor surface: fixture lots credit through `creditFromGrant`, the
|
|
957
|
+
* backend finalizer redeems through `finalize`, and owner-side mistakes in a
|
|
958
|
+
* vector surface as script errors.
|
|
941
959
|
*/
|
|
942
960
|
export function createCommerceConformanceWallet(input) {
|
|
943
|
-
const { clock
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
const subscriptions = new Map();
|
|
950
|
-
const subscriptionEvents = [];
|
|
951
|
-
const listeners = new Set();
|
|
952
|
-
const buffer = createCommerceEventBuffer();
|
|
953
|
-
const snapshotLot = (lot) => ({ ...lot });
|
|
954
|
-
const toEntitlement = (entitlement) => ({
|
|
955
|
-
entitlementId: entitlement.entitlementId,
|
|
956
|
-
titleId: entitlement.titleId,
|
|
957
|
-
logicalId: entitlement.logicalId,
|
|
958
|
-
idempotencyKey: entitlement.idempotencyKey,
|
|
959
|
-
createdAt: entitlement.createdAt,
|
|
960
|
-
debits: [...entitlement.debits],
|
|
961
|
-
});
|
|
962
|
-
const toActiveSubscription = (subscription) => ({
|
|
963
|
-
store,
|
|
964
|
-
logicalId: subscription.logicalId,
|
|
965
|
-
subscriptionId: subscription.subscriptionId,
|
|
966
|
-
periodIndex: subscription.periodIndex,
|
|
967
|
-
state: subscription.state,
|
|
968
|
-
startedAt: subscription.startedAt,
|
|
969
|
-
expiresAt: subscription.expiresAt,
|
|
970
|
-
autoRenew: subscription.state === 'active',
|
|
961
|
+
const { clock } = input;
|
|
962
|
+
const wallet = createInMemoryCreditWallet({
|
|
963
|
+
store: input.store,
|
|
964
|
+
currencies: input.currencies,
|
|
965
|
+
now: () => clock.now(),
|
|
966
|
+
...(input.offers === undefined ? {} : { offers: input.offers }),
|
|
971
967
|
});
|
|
972
|
-
const emitSubscription = (subscription, type, periodIndex, reason) => {
|
|
973
|
-
const eventId = `${subscription.subscriptionId}:${periodIndex}:${type}`;
|
|
974
|
-
if (subscriptionEvents.some((event) => event.eventId === eventId)) {
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
subscriptionEvents.push({
|
|
978
|
-
eventId,
|
|
979
|
-
type,
|
|
980
|
-
subscriptionId: subscription.subscriptionId,
|
|
981
|
-
logicalId: subscription.logicalId,
|
|
982
|
-
periodIndex,
|
|
983
|
-
expiresAt: subscription.expiresAt,
|
|
984
|
-
...(reason === undefined ? {} : { reason }),
|
|
985
|
-
});
|
|
986
|
-
const event = {
|
|
987
|
-
type: 'subscriptionUpdated',
|
|
988
|
-
subscription: toActiveSubscription(subscription),
|
|
989
|
-
};
|
|
990
|
-
if (listeners.size === 0) {
|
|
991
|
-
buffer.push(event);
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
for (const listener of listeners) {
|
|
995
|
-
listener(event);
|
|
996
|
-
}
|
|
997
|
-
};
|
|
998
|
-
const eligibleLots = (titleId, currency, scope) => {
|
|
999
|
-
const now = Date.parse(clock.now());
|
|
1000
|
-
return lots
|
|
1001
|
-
.filter((lot) => lot.currency === currency
|
|
1002
|
-
&& lot.remaining > 0
|
|
1003
|
-
&& (lot.expiresAt === undefined || Date.parse(lot.expiresAt) > now)
|
|
1004
|
-
&& (lot.spendScope.kind === 'cross-title'
|
|
1005
|
-
|| (scope === 'title' && lot.spendScope.titleId === titleId)))
|
|
1006
|
-
.sort(compareCreditLotSpendOrder);
|
|
1007
|
-
};
|
|
1008
|
-
const spendScoped = async (request, scope) => {
|
|
1009
|
-
const replay = spends.get(request.idempotencyKey);
|
|
1010
|
-
if (replay !== undefined) {
|
|
1011
|
-
return {
|
|
1012
|
-
status: 'debited',
|
|
1013
|
-
entitlementId: replay.entitlementId,
|
|
1014
|
-
debits: [...replay.debits],
|
|
1015
|
-
alreadyProcessed: true,
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
if (!currencies.has(request.currency)) {
|
|
1019
|
-
return {
|
|
1020
|
-
status: 'rejected',
|
|
1021
|
-
error: {
|
|
1022
|
-
code: 'ITEM_UNAVAILABLE',
|
|
1023
|
-
retryable: false,
|
|
1024
|
-
message: `Currency ${request.currency} is not declared by the wallet.`,
|
|
1025
|
-
},
|
|
1026
|
-
};
|
|
1027
|
-
}
|
|
1028
|
-
if (!(request.amount > 0)) {
|
|
1029
|
-
return {
|
|
1030
|
-
status: 'rejected',
|
|
1031
|
-
error: {
|
|
1032
|
-
code: 'INVALID_RESPONSE',
|
|
1033
|
-
retryable: false,
|
|
1034
|
-
message: 'Spend amount must be positive.',
|
|
1035
|
-
},
|
|
1036
|
-
};
|
|
1037
|
-
}
|
|
1038
|
-
const candidates = eligibleLots(request.titleId, request.currency, scope);
|
|
1039
|
-
const available = candidates.reduce((sum, lot) => sum + lot.remaining, 0);
|
|
1040
|
-
if (available < request.amount) {
|
|
1041
|
-
return {
|
|
1042
|
-
status: 'rejected',
|
|
1043
|
-
error: {
|
|
1044
|
-
code: 'INSUFFICIENT_BALANCE',
|
|
1045
|
-
retryable: false,
|
|
1046
|
-
message: `Eligible ${request.currency} balance ${available} cannot cover ${request.amount}.`,
|
|
1047
|
-
},
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1050
|
-
const spent = [];
|
|
1051
|
-
let remaining = request.amount;
|
|
1052
|
-
for (const lot of candidates) {
|
|
1053
|
-
if (remaining <= 0) {
|
|
1054
|
-
break;
|
|
1055
|
-
}
|
|
1056
|
-
const amount = Math.min(lot.remaining, remaining);
|
|
1057
|
-
lot.remaining -= amount;
|
|
1058
|
-
remaining -= amount;
|
|
1059
|
-
spent.push({ lotId: lot.lotId, amount });
|
|
1060
|
-
debits.push({ idempotencyKey: request.idempotencyKey, lotId: lot.lotId, amount });
|
|
1061
|
-
}
|
|
1062
|
-
const entitlementId = request.idempotencyKey;
|
|
1063
|
-
spends.set(request.idempotencyKey, { entitlementId, debits: spent });
|
|
1064
|
-
if (request.logicalId !== undefined) {
|
|
1065
|
-
entitlements.push({
|
|
1066
|
-
entitlementId,
|
|
1067
|
-
titleId: request.titleId,
|
|
1068
|
-
logicalId: request.logicalId,
|
|
1069
|
-
idempotencyKey: request.idempotencyKey,
|
|
1070
|
-
createdAt: clock.now(),
|
|
1071
|
-
debits: [...spent],
|
|
1072
|
-
redeemed: false,
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
|
-
return { status: 'debited', entitlementId, debits: [...spent] };
|
|
1076
|
-
};
|
|
1077
|
-
const renew = async (subscription) => {
|
|
1078
|
-
while (subscription.state === 'active' && Date.parse(subscription.expiresAt) <= Date.parse(clock.now())) {
|
|
1079
|
-
const nextPeriod = subscription.periodIndex + 1;
|
|
1080
|
-
const result = await spendScoped({
|
|
1081
|
-
titleId: subscription.titleId,
|
|
1082
|
-
currency: subscription.terms.price.currency,
|
|
1083
|
-
amount: subscription.terms.price.amount,
|
|
1084
|
-
idempotencyKey: `membership:${subscription.subscriptionId}:${nextPeriod}`,
|
|
1085
|
-
}, subscription.terms.scope);
|
|
1086
|
-
if (result.status === 'rejected') {
|
|
1087
|
-
subscription.state = 'lapsed';
|
|
1088
|
-
emitSubscription(subscription, 'subscriptionLapsed', nextPeriod, result.error.code);
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
subscription.periodIndex = nextPeriod;
|
|
1092
|
-
subscription.expiresAt = addIsoDuration(subscription.expiresAt, subscription.terms.period);
|
|
1093
|
-
emitSubscription(subscription, 'subscriptionRenewed', nextPeriod);
|
|
1094
|
-
}
|
|
1095
|
-
};
|
|
1096
968
|
return {
|
|
1097
|
-
store,
|
|
969
|
+
store: wallet.store,
|
|
1098
970
|
get lots() {
|
|
1099
|
-
return lots
|
|
971
|
+
return wallet.lots;
|
|
1100
972
|
},
|
|
1101
973
|
get debits() {
|
|
1102
|
-
return
|
|
974
|
+
return wallet.debits;
|
|
1103
975
|
},
|
|
1104
976
|
get entitlements() {
|
|
1105
|
-
return entitlements
|
|
977
|
+
return wallet.entitlements;
|
|
1106
978
|
},
|
|
1107
979
|
get subscriptionEvents() {
|
|
1108
|
-
return
|
|
980
|
+
return wallet.subscriptionEvents;
|
|
1109
981
|
},
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
if (existing !== undefined) {
|
|
1113
|
-
return snapshotLot(existing);
|
|
1114
|
-
}
|
|
1115
|
-
if (!currencies.has(fixture.currency)) {
|
|
1116
|
-
throw new CommerceConformanceScriptError(`Lot ${fixture.ledgerEntryId} uses currency ${fixture.currency}, which the wallet does not declare.`);
|
|
1117
|
-
}
|
|
1118
|
-
const lot = {
|
|
1119
|
-
lotId: fixture.ledgerEntryId,
|
|
1120
|
-
currency: fixture.currency,
|
|
1121
|
-
amount: fixture.amount,
|
|
1122
|
-
remaining: fixture.amount,
|
|
1123
|
-
createdAt: normalizeIsoTimestamp(fixture.createdAt),
|
|
1124
|
-
...(fixture.expiresAt === undefined
|
|
1125
|
-
? {}
|
|
1126
|
-
: { expiresAt: normalizeIsoTimestamp(fixture.expiresAt) }),
|
|
1127
|
-
origin: { ...fixture.origin },
|
|
1128
|
-
spendScope: deriveCreditLotSpendScope(fixture.origin, options.spendScope),
|
|
1129
|
-
};
|
|
1130
|
-
lots.push(lot);
|
|
1131
|
-
return snapshotLot(lot);
|
|
982
|
+
get offers() {
|
|
983
|
+
return wallet.offers;
|
|
1132
984
|
},
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
const current = subscriptions.get(request.subscriptionId);
|
|
1136
|
-
if (current !== undefined && current.state === 'active') {
|
|
1137
|
-
return {
|
|
1138
|
-
status: 'started',
|
|
1139
|
-
subscription: toActiveSubscription(current),
|
|
1140
|
-
entitlementId: `membership:${request.subscriptionId}:0`,
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
// The terms are validated before any credit moves, so malformed terms never charge.
|
|
1144
|
-
const startedAt = clock.now();
|
|
1145
|
-
let expiresAt;
|
|
985
|
+
addOffers: (offers) => wallet.addOffers(offers),
|
|
986
|
+
creditFromGrant(fixture, options = {}) {
|
|
1146
987
|
try {
|
|
1147
|
-
|
|
1148
|
-
}
|
|
1149
|
-
catch {
|
|
1150
|
-
return {
|
|
1151
|
-
status: 'rejected',
|
|
1152
|
-
error: {
|
|
1153
|
-
code: 'ITEM_UNAVAILABLE',
|
|
1154
|
-
retryable: false,
|
|
1155
|
-
message: `Membership period ${request.terms.period} is not an ISO-8601 duration.`,
|
|
1156
|
-
},
|
|
1157
|
-
};
|
|
1158
|
-
}
|
|
1159
|
-
const result = await spendScoped({
|
|
1160
|
-
titleId: request.titleId,
|
|
1161
|
-
currency: request.terms.price.currency,
|
|
1162
|
-
amount: request.terms.price.amount,
|
|
1163
|
-
idempotencyKey: `membership:${request.subscriptionId}:0`,
|
|
1164
|
-
logicalId: request.logicalId,
|
|
1165
|
-
}, request.terms.scope);
|
|
1166
|
-
if (result.status === 'rejected') {
|
|
1167
|
-
return result;
|
|
1168
|
-
}
|
|
1169
|
-
const subscription = {
|
|
1170
|
-
subscriptionId: request.subscriptionId,
|
|
1171
|
-
titleId: request.titleId,
|
|
1172
|
-
logicalId: request.logicalId,
|
|
1173
|
-
terms: request.terms,
|
|
1174
|
-
periodIndex: 0,
|
|
1175
|
-
startedAt,
|
|
1176
|
-
expiresAt,
|
|
1177
|
-
state: 'active',
|
|
1178
|
-
};
|
|
1179
|
-
subscriptions.set(subscription.subscriptionId, subscription);
|
|
1180
|
-
emitSubscription(subscription, 'subscriptionStarted', 0);
|
|
1181
|
-
return {
|
|
1182
|
-
status: 'started',
|
|
1183
|
-
subscription: toActiveSubscription(subscription),
|
|
1184
|
-
entitlementId: result.entitlementId,
|
|
1185
|
-
};
|
|
1186
|
-
},
|
|
1187
|
-
async renewDue() {
|
|
1188
|
-
for (const subscription of subscriptions.values()) {
|
|
1189
|
-
await renew(subscription);
|
|
1190
|
-
}
|
|
1191
|
-
},
|
|
1192
|
-
async listOffers() {
|
|
1193
|
-
return [...(input.offers ?? [])];
|
|
1194
|
-
},
|
|
1195
|
-
async listUnredeemed(request) {
|
|
1196
|
-
return entitlements
|
|
1197
|
-
.filter((entitlement) => !entitlement.redeemed && entitlement.titleId === request.titleId)
|
|
1198
|
-
.map(toEntitlement);
|
|
1199
|
-
},
|
|
1200
|
-
async redeem(request) {
|
|
1201
|
-
const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.entitlementId);
|
|
1202
|
-
if (entitlement === undefined) {
|
|
1203
|
-
return { redeemed: false };
|
|
1204
|
-
}
|
|
1205
|
-
entitlement.redeemed = true;
|
|
1206
|
-
return { redeemed: true };
|
|
1207
|
-
},
|
|
1208
|
-
finalize(request) {
|
|
1209
|
-
const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.transactionId);
|
|
1210
|
-
if (entitlement !== undefined) {
|
|
1211
|
-
entitlement.redeemed = true;
|
|
988
|
+
return wallet.creditFromGrant(fixture, options.spendScope === undefined ? {} : { explicit: options.spendScope });
|
|
1212
989
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const balances = {};
|
|
1216
|
-
for (const currency of currencies) {
|
|
1217
|
-
balances[currency] = eligibleLots(request.titleId, currency, 'title').reduce((sum, lot) => sum + lot.remaining, 0);
|
|
990
|
+
catch (error) {
|
|
991
|
+
throw new CommerceConformanceScriptError(describeError(error));
|
|
1218
992
|
}
|
|
1219
|
-
return { titleId: request.titleId, balances };
|
|
1220
993
|
},
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
}
|
|
1232
|
-
return () => {
|
|
1233
|
-
listeners.delete(listener);
|
|
1234
|
-
};
|
|
994
|
+
spend: (request) => wallet.spend(request),
|
|
995
|
+
startMembership: (request) => wallet.startMembership(request),
|
|
996
|
+
renewDue: () => wallet.renewDue(),
|
|
997
|
+
listOffers: (request) => wallet.listOffers(request),
|
|
998
|
+
listUnredeemed: (request) => wallet.listUnredeemed(request),
|
|
999
|
+
redeem: (request) => wallet.redeem(request),
|
|
1000
|
+
async finalize(request) {
|
|
1001
|
+
await wallet.redeem({
|
|
1002
|
+
entitlementId: request.transactionId,
|
|
1003
|
+
ledgerEntryId: request.ledgerEntryId,
|
|
1004
|
+
});
|
|
1235
1005
|
},
|
|
1006
|
+
getBalance: (request) => wallet.getBalance(request),
|
|
1007
|
+
getActiveSubscriptions: (request) => wallet.getActiveSubscriptions(request),
|
|
1008
|
+
subscribe: (listener, request) => wallet.subscribe(listener, request),
|
|
1236
1009
|
};
|
|
1237
1010
|
}
|
|
1238
|
-
/** Store lots are title-scoped, off-store lots cross-title, promotion lots as issued (title by default). */
|
|
1239
|
-
export function deriveCreditLotSpendScope(origin, explicit) {
|
|
1240
|
-
switch (origin.channel) {
|
|
1241
|
-
case 'store':
|
|
1242
|
-
return { kind: 'title', titleId: origin.titleId };
|
|
1243
|
-
case 'off-store':
|
|
1244
|
-
return { kind: 'cross-title' };
|
|
1245
|
-
case 'promotion':
|
|
1246
|
-
return explicit ?? { kind: 'title', titleId: origin.titleId };
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
/** RFC spend order: earliest `expiresAt` (none last), `title` before `cross-title`, oldest `createdAt`, then `lotId`. */
|
|
1250
|
-
export function compareCreditLotSpendOrder(left, right) {
|
|
1251
|
-
const leftExpiry = left.expiresAt === undefined
|
|
1252
|
-
? Number.POSITIVE_INFINITY
|
|
1253
|
-
: Date.parse(left.expiresAt);
|
|
1254
|
-
const rightExpiry = right.expiresAt === undefined
|
|
1255
|
-
? Number.POSITIVE_INFINITY
|
|
1256
|
-
: Date.parse(right.expiresAt);
|
|
1257
|
-
if (leftExpiry !== rightExpiry) {
|
|
1258
|
-
return leftExpiry < rightExpiry ? -1 : 1;
|
|
1259
|
-
}
|
|
1260
|
-
if (left.spendScope.kind !== right.spendScope.kind) {
|
|
1261
|
-
return left.spendScope.kind === 'title' ? -1 : 1;
|
|
1262
|
-
}
|
|
1263
|
-
const createdDelta = Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
|
1264
|
-
if (createdDelta !== 0) {
|
|
1265
|
-
return createdDelta < 0 ? -1 : 1;
|
|
1266
|
-
}
|
|
1267
|
-
return left.lotId < right.lotId ? -1 : left.lotId > right.lotId ? 1 : 0;
|
|
1268
|
-
}
|
|
1269
1011
|
/**
|
|
1270
|
-
* `
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
* `host`.
|
|
1012
|
+
* `createCreditCommerceProvider` over the reference wallet with the vector
|
|
1013
|
+
* profile as its capability block. A wallet that lists no offers has the
|
|
1014
|
+
* vector catalog registered as its offers first (a fixture price of one unit
|
|
1015
|
+
* of the first wallet currency for entries without membership terms), so the
|
|
1016
|
+
* wallet prices every spend and starts memberships itself. Memberships exist
|
|
1017
|
+
* only where the billing authority is `host`.
|
|
1277
1018
|
*/
|
|
1278
1019
|
export function createCommerceConformanceWalletProvider(input) {
|
|
1279
1020
|
const { wallet, catalog, titleId, clock } = input;
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const base = createCommerceConformanceCapabilities(input.profile);
|
|
1285
|
-
return {
|
|
1286
|
-
...base,
|
|
1287
|
-
purchase: { ...base.purchase, subscription: base.purchase.subscription && membershipsOffered },
|
|
1288
|
-
};
|
|
1289
|
-
};
|
|
1021
|
+
const defaultCurrency = input.currencies[0];
|
|
1022
|
+
if (defaultCurrency === undefined) {
|
|
1023
|
+
throw new CommerceConformanceScriptError('The wallet profile declares no currencies.');
|
|
1024
|
+
}
|
|
1290
1025
|
const toOffer = (entry) => ({
|
|
1291
1026
|
logicalId: entry.logicalId,
|
|
1292
1027
|
storeSku: entry.storeSku,
|
|
@@ -1297,112 +1032,23 @@ export function createCommerceConformanceWalletProvider(input) {
|
|
|
1297
1032
|
price: entry.membership?.price ?? { currency: defaultCurrency, amount: 1 },
|
|
1298
1033
|
...(entry.membership === undefined ? {} : { membership: entry.membership }),
|
|
1299
1034
|
});
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
purchasedAt: createdAt,
|
|
1315
|
-
token: entitlementId,
|
|
1316
|
-
evidence: { schema: `fixture.${store}.entitlement.v1`, payload: { entitlementId } },
|
|
1317
|
-
accountBinding: { kind: 'host-identity' },
|
|
1318
|
-
settlement: { ledgerEntryId: entitlementId },
|
|
1319
|
-
});
|
|
1320
|
-
const unavailable = (message) => ({
|
|
1321
|
-
outcome: 'failed',
|
|
1322
|
-
error: new CommerceError('ITEM_UNAVAILABLE', { message }).toCommerceErrorInfo(),
|
|
1323
|
-
});
|
|
1324
|
-
return {
|
|
1325
|
-
store,
|
|
1326
|
-
async getCapabilities() {
|
|
1327
|
-
return capabilities();
|
|
1328
|
-
},
|
|
1329
|
-
async fetchProducts(request) {
|
|
1330
|
-
return (await listOffers())
|
|
1331
|
-
.filter((offer) => matchesFetchProducts(offer, request))
|
|
1332
|
-
.map((offer) => ({
|
|
1333
|
-
logicalId: offer.logicalId,
|
|
1334
|
-
store,
|
|
1335
|
-
storeSku: offer.storeSku,
|
|
1336
|
-
kind: offer.kind,
|
|
1337
|
-
title: offer.title,
|
|
1338
|
-
description: offer.description,
|
|
1339
|
-
price: {
|
|
1340
|
-
formatted: `${offer.price.amount} ${offer.price.currency}`,
|
|
1341
|
-
currencyCode: offer.price.currency,
|
|
1342
|
-
},
|
|
1343
|
-
}));
|
|
1344
|
-
},
|
|
1345
|
-
async requestPurchase(request) {
|
|
1346
|
-
const offer = (await listOffers()).find((candidate) => candidate.logicalId === request.logicalId);
|
|
1347
|
-
if (offer === undefined) {
|
|
1348
|
-
return unavailable(`The wallet offers no product ${request.logicalId}.`);
|
|
1349
|
-
}
|
|
1350
|
-
const quantity = request.quantity ?? 1;
|
|
1351
|
-
if (quantity > 1 && !capabilities().purchase.quantity) {
|
|
1352
|
-
return unavailable('The wallet does not support purchase quantities above 1.');
|
|
1353
|
-
}
|
|
1354
|
-
if (offer.membership !== undefined) {
|
|
1355
|
-
if (quantity > 1) {
|
|
1356
|
-
return unavailable('A membership is purchased one period at a time.');
|
|
1357
|
-
}
|
|
1358
|
-
const started = await wallet.startMembership({
|
|
1359
|
-
subscriptionId: request.idempotencyKey,
|
|
1360
|
-
titleId,
|
|
1361
|
-
logicalId: offer.logicalId,
|
|
1362
|
-
terms: offer.membership,
|
|
1363
|
-
});
|
|
1364
|
-
if (started.status === 'rejected') {
|
|
1365
|
-
return { outcome: 'failed', error: started.error };
|
|
1366
|
-
}
|
|
1367
|
-
quantities.set(started.entitlementId, 1);
|
|
1368
|
-
return {
|
|
1369
|
-
outcome: 'purchased',
|
|
1370
|
-
purchase: toPurchase(offer, started.entitlementId, started.subscription.startedAt, 1),
|
|
1371
|
-
};
|
|
1372
|
-
}
|
|
1373
|
-
const spend = await wallet.spend({
|
|
1374
|
-
titleId,
|
|
1375
|
-
currency: offer.price.currency,
|
|
1376
|
-
amount: offer.price.amount * quantity,
|
|
1377
|
-
idempotencyKey: request.idempotencyKey,
|
|
1378
|
-
logicalId: offer.logicalId,
|
|
1379
|
-
});
|
|
1380
|
-
if (spend.status === 'rejected') {
|
|
1381
|
-
return { outcome: 'failed', error: spend.error };
|
|
1382
|
-
}
|
|
1383
|
-
quantities.set(spend.entitlementId, quantity);
|
|
1384
|
-
return {
|
|
1385
|
-
outcome: 'purchased',
|
|
1386
|
-
purchase: toPurchase(offer, spend.entitlementId, clock.now(), quantity),
|
|
1387
|
-
};
|
|
1388
|
-
},
|
|
1389
|
-
async getAvailablePurchases() {
|
|
1390
|
-
const offers = await listOffers();
|
|
1391
|
-
const unredeemed = await wallet.listUnredeemed({ titleId });
|
|
1392
|
-
return unredeemed.flatMap((entitlement) => {
|
|
1393
|
-
const offer = offers.find((candidate) => candidate.logicalId === entitlement.logicalId);
|
|
1394
|
-
return offer === undefined
|
|
1395
|
-
? []
|
|
1396
|
-
: [toPurchase(offer, entitlement.entitlementId, entitlement.createdAt, quantities.get(entitlement.entitlementId) ?? 1)];
|
|
1397
|
-
});
|
|
1398
|
-
},
|
|
1399
|
-
async getActiveSubscriptions() {
|
|
1400
|
-
return wallet.getActiveSubscriptions();
|
|
1401
|
-
},
|
|
1402
|
-
subscribe(listener) {
|
|
1403
|
-
return wallet.subscribe(listener);
|
|
1035
|
+
if (wallet.offers.length === 0) {
|
|
1036
|
+
wallet.addOffers(catalog.map(toOffer));
|
|
1037
|
+
}
|
|
1038
|
+
const capabilities = createCommerceConformanceCapabilities(input.profile);
|
|
1039
|
+
return createCreditCommerceProvider(wallet, {
|
|
1040
|
+
titleId,
|
|
1041
|
+
billingAuthority: input.billingAuthority,
|
|
1042
|
+
currencies: input.currencies,
|
|
1043
|
+
capabilities: {
|
|
1044
|
+
purchase: capabilities.purchase,
|
|
1045
|
+
catalog: capabilities.catalog,
|
|
1046
|
+
lifecycle: capabilities.lifecycle,
|
|
1047
|
+
authority: capabilities.authority,
|
|
1048
|
+
availability: capabilities.availability,
|
|
1404
1049
|
},
|
|
1405
|
-
|
|
1050
|
+
now: () => clock.now(),
|
|
1051
|
+
});
|
|
1406
1052
|
}
|
|
1407
1053
|
export function createCommerceConformanceHarness(context) {
|
|
1408
1054
|
if (context.vector.wallet === undefined) {
|
|
@@ -1430,31 +1076,153 @@ export function createCommerceConformanceHarness(context) {
|
|
|
1430
1076
|
* each, then matches `expect`. Matching compares only the listed fields; arrays
|
|
1431
1077
|
* compare in order and by length; a listed `journal` is the whole journal.
|
|
1432
1078
|
* Any unscripted operation fails the vector at the step that needed it.
|
|
1079
|
+
*
|
|
1080
|
+
* With `options.createProvider`, a vector flagged `profile.adaptive` is first
|
|
1081
|
+
* checked against the provider's declared capabilities
|
|
1082
|
+
* (`commerceConformanceAdaptiveSkipReason`) and then matched against
|
|
1083
|
+
* expectations derived from a reference run under those capabilities
|
|
1084
|
+
* (`deriveAdaptiveCommerceConformanceExpectation`). Duplicate detection needs
|
|
1085
|
+
* the whole run set, so it applies through `runCommerceConformanceVectors`.
|
|
1433
1086
|
*/
|
|
1434
1087
|
export async function runCommerceConformanceVector(vector, options = {}) {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1088
|
+
return runVectorIn(vector, options, purchaseFinishFamilyOf([vector]));
|
|
1089
|
+
}
|
|
1090
|
+
export async function runCommerceConformanceVectors(vectors, options = {}) {
|
|
1091
|
+
// The `purchase-<owner>-finish` family of this run is fixed up front; the
|
|
1092
|
+
// canonical member per owner is then one lookup per vector.
|
|
1093
|
+
const family = purchaseFinishFamilyOf(vectors);
|
|
1094
|
+
const results = [];
|
|
1095
|
+
for (const vector of vectors) {
|
|
1096
|
+
results.push(await runVectorIn(vector, options, family));
|
|
1097
|
+
}
|
|
1098
|
+
return results;
|
|
1099
|
+
}
|
|
1100
|
+
async function runVectorIn(vector, options, family) {
|
|
1101
|
+
const ids = {
|
|
1102
|
+
playerId: options.playerId ?? commerceConformanceDefaultPlayerId,
|
|
1103
|
+
titleId: options.titleId ?? commerceConformanceDefaultTitleId,
|
|
1104
|
+
};
|
|
1105
|
+
const failed = (failures) => ({ vector: vector.vector, status: 'failed', passed: false, failures });
|
|
1106
|
+
const skipped = (reason) => ({ vector: vector.vector, status: 'skipped', passed: false, failures: [], reason });
|
|
1107
|
+
// A harness that cannot be built is a failed run, never a rejected promise.
|
|
1108
|
+
let execution;
|
|
1109
|
+
try {
|
|
1110
|
+
execution = await prepareExecution(vector, options.createProvider ?? createCommerceConformanceHarness, ids);
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
return failed([
|
|
1114
|
+
{ path: 'harness', expected: 'createProvider resolves', actual: describeError(error) },
|
|
1115
|
+
]);
|
|
1116
|
+
}
|
|
1117
|
+
let expectation = vector.expect;
|
|
1118
|
+
let adaptive;
|
|
1119
|
+
let adaptation;
|
|
1120
|
+
if (options.createProvider !== undefined && vector.profile.adaptive === true) {
|
|
1121
|
+
let capabilities;
|
|
1122
|
+
try {
|
|
1123
|
+
capabilities = await execution.harness.provider.getCapabilities();
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
return failed([
|
|
1127
|
+
{
|
|
1128
|
+
path: 'capabilities',
|
|
1129
|
+
expected: 'provider.getCapabilities() resolves',
|
|
1130
|
+
actual: describeError(error),
|
|
1131
|
+
},
|
|
1132
|
+
]);
|
|
1133
|
+
}
|
|
1134
|
+
const reason = adaptiveSkipReasonWith(vector, capabilities, family);
|
|
1135
|
+
if (reason !== undefined) {
|
|
1136
|
+
return skipped(reason);
|
|
1137
|
+
}
|
|
1138
|
+
const store = execution.harness.provider.store;
|
|
1139
|
+
adaptation = adaptationFor(vector, capabilities);
|
|
1140
|
+
// The reference run reports the tokens the provider under test reports.
|
|
1141
|
+
const tokenOf = execution.harness.store?.tokenOf;
|
|
1142
|
+
const tokenAdaptedVector = tokenOf === undefined
|
|
1143
|
+
? vector
|
|
1144
|
+
: withStoreTokens(vector, (purchase) => tokenOf.call(execution.harness.store, purchase));
|
|
1145
|
+
let reference;
|
|
1146
|
+
try {
|
|
1147
|
+
reference = await prepareExecution(referenceVectorFor(tokenAdaptedVector, store, capabilities), createCommerceConformanceHarness, ids);
|
|
1148
|
+
}
|
|
1149
|
+
catch (error) {
|
|
1150
|
+
return failed([
|
|
1151
|
+
{
|
|
1152
|
+
path: 'reference.harness',
|
|
1153
|
+
expected: 'reference harness builds',
|
|
1154
|
+
actual: describeError(error),
|
|
1155
|
+
},
|
|
1156
|
+
]);
|
|
1157
|
+
}
|
|
1158
|
+
const referenceFailures = await executeSteps(reference, adaptation);
|
|
1159
|
+
const referenceFailure = referenceFailures[0];
|
|
1160
|
+
if (referenceFailure !== undefined) {
|
|
1161
|
+
// The reference pieces cannot play the scenario under these capabilities,
|
|
1162
|
+
// which is a property of the vector and the capability set, not of the
|
|
1163
|
+
// provider under test; the step and its error are kept in the reason.
|
|
1164
|
+
return skipped(`the reference run under the provider capabilities failed at ${referenceFailure.path}: `
|
|
1165
|
+
+ `expected ${String(referenceFailure.expected)}, got ${String(referenceFailure.actual)}`);
|
|
1166
|
+
}
|
|
1167
|
+
const derived = await deriveAdaptiveExpectation(tokenAdaptedVector, reference, store);
|
|
1168
|
+
if (derived.reason !== undefined) {
|
|
1169
|
+
return skipped(derived.reason);
|
|
1170
|
+
}
|
|
1171
|
+
expectation = derived.expect;
|
|
1172
|
+
adaptive = {
|
|
1173
|
+
store,
|
|
1174
|
+
finish: capabilities.lifecycle.finish,
|
|
1175
|
+
evidence: capabilities.authority.evidence,
|
|
1176
|
+
expect: expectation,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const failures = await executeSteps(execution, adaptation);
|
|
1180
|
+
if (failures.length > 0) {
|
|
1181
|
+
return { ...failed(failures), ...(adaptive === undefined ? {} : { adaptive }) };
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
await matchExpectations(expectation, { ...execution, failures });
|
|
1185
|
+
}
|
|
1186
|
+
catch (error) {
|
|
1187
|
+
// A provider read that throws while expectations are evaluated is a failed
|
|
1188
|
+
// run, never a rejected promise.
|
|
1189
|
+
failures.push({
|
|
1190
|
+
path: 'expect',
|
|
1191
|
+
expected: 'expectations evaluate',
|
|
1192
|
+
actual: describeError(error),
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
return {
|
|
1196
|
+
vector: vector.vector,
|
|
1197
|
+
status: failures.length === 0 ? 'passed' : 'failed',
|
|
1198
|
+
passed: failures.length === 0,
|
|
1199
|
+
failures,
|
|
1200
|
+
...(adaptive === undefined ? {} : { adaptive }),
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
async function prepareExecution(vector, createHarness, ids) {
|
|
1438
1204
|
const clock = createCommerceConformanceClock();
|
|
1439
1205
|
const journal = createCommerceConformanceJournal();
|
|
1440
1206
|
const harnessRef = {};
|
|
1207
|
+
const adaptation = { current: undefined };
|
|
1441
1208
|
const backend = createCommerceConformanceBackend({
|
|
1442
1209
|
finalize: async (request) => {
|
|
1443
1210
|
await harnessRef.current?.store?.finalize?.(request);
|
|
1444
1211
|
await harnessRef.current?.wallet?.finalize?.(request);
|
|
1445
1212
|
},
|
|
1213
|
+
finalizeOnReread: () => adaptation.current?.serverFinalization === true,
|
|
1446
1214
|
});
|
|
1447
1215
|
const context = {
|
|
1448
1216
|
vector,
|
|
1449
1217
|
profile: vector.profile,
|
|
1450
1218
|
catalog: vector.catalog ?? [],
|
|
1451
|
-
playerId,
|
|
1452
|
-
titleId,
|
|
1219
|
+
playerId: ids.playerId,
|
|
1220
|
+
titleId: ids.titleId,
|
|
1453
1221
|
clock,
|
|
1454
1222
|
backend,
|
|
1455
1223
|
journal,
|
|
1456
1224
|
};
|
|
1457
|
-
const harness = await (
|
|
1225
|
+
const harness = await createHarness(context);
|
|
1458
1226
|
harnessRef.current = harness;
|
|
1459
1227
|
const runtime = createCommerceConformanceRuntime({
|
|
1460
1228
|
provider: harness.provider,
|
|
@@ -1462,32 +1230,330 @@ export async function runCommerceConformanceVector(vector, options = {}) {
|
|
|
1462
1230
|
journal,
|
|
1463
1231
|
clock,
|
|
1464
1232
|
catalog: context.catalog,
|
|
1465
|
-
playerId,
|
|
1233
|
+
playerId: ids.playerId,
|
|
1466
1234
|
});
|
|
1467
|
-
|
|
1235
|
+
return { vector, harness, runtime, backend, journal, clock, adaptation };
|
|
1236
|
+
}
|
|
1237
|
+
/** Runs every step; a thrown step ends the run with a failure at that step. */
|
|
1238
|
+
async function executeSteps(execution, adaptation) {
|
|
1239
|
+
const failures = [];
|
|
1240
|
+
execution.adaptation.current = adaptation;
|
|
1241
|
+
const context = { ...execution, failures };
|
|
1242
|
+
for (const [index, step] of execution.vector.steps.entries()) {
|
|
1468
1243
|
const path = `steps[${index}]`;
|
|
1469
1244
|
try {
|
|
1470
|
-
await runStep(step, path,
|
|
1471
|
-
await runtime.runUntilIdle();
|
|
1245
|
+
await runStep(step, path, context);
|
|
1246
|
+
await execution.runtime.runUntilIdle();
|
|
1472
1247
|
}
|
|
1473
1248
|
catch (error) {
|
|
1474
1249
|
failures.push({
|
|
1475
1250
|
path,
|
|
1476
1251
|
expected: `${step.actor}.${step.action} completes`,
|
|
1477
|
-
actual:
|
|
1252
|
+
actual: describeError(error),
|
|
1478
1253
|
});
|
|
1479
|
-
|
|
1254
|
+
break;
|
|
1480
1255
|
}
|
|
1481
1256
|
}
|
|
1482
|
-
|
|
1483
|
-
return { vector: vector.vector, passed: failures.length === 0, failures };
|
|
1257
|
+
return failures;
|
|
1484
1258
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1259
|
+
function describeError(error) {
|
|
1260
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
1261
|
+
}
|
|
1262
|
+
// Adaptive vectors (RFC "Conformance vectors", adaptive vectors).
|
|
1263
|
+
/**
|
|
1264
|
+
* Members of the `purchase-<owner>-finish` family describe the same purchase
|
|
1265
|
+
* under different fixture finish owners; a provider runs the one named after
|
|
1266
|
+
* its own owner, or the first by name when none is (`finish: 'none'`).
|
|
1267
|
+
*/
|
|
1268
|
+
const purchaseFinishFamily = /^purchase-(?:client|server|host|none)-finish$/u;
|
|
1269
|
+
export function isAdaptiveCommerceConformanceVector(vector) {
|
|
1270
|
+
return vector.profile.adaptive === true;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Why an adaptive vector does not apply to a provider with `capabilities`, or
|
|
1274
|
+
* `undefined` when it runs. `set` is the whole run (for duplicate detection).
|
|
1275
|
+
* The rules:
|
|
1276
|
+
*
|
|
1277
|
+
* - a `game.requestPurchase` of a product kind the provider does not sell
|
|
1278
|
+
* (`purchase.<kind>` false, by the vector catalog), or with a quantity above
|
|
1279
|
+
* one without `purchase.quantity`, cannot open the store UI;
|
|
1280
|
+
* - a store-side `pending` purchase needs `lifecycle.pending`;
|
|
1281
|
+
* - a store `error` event needs `lifecycle.events`;
|
|
1282
|
+
* - without `lifecycle.events`, a store `complete` or `update` reaches the
|
|
1283
|
+
* reconciler only by polling, so the next `runtime` step after it must be
|
|
1284
|
+
* `reconcile` or `start`;
|
|
1285
|
+
* - a `queueFinish` script needs a `client` or `host` finish owner, since the
|
|
1286
|
+
* provider never calls `finishTransaction` under `server` or `none`;
|
|
1287
|
+
* - within the `purchase-<owner>-finish` family only one member runs: the
|
|
1288
|
+
* one named after the provider's owner, else the first by name.
|
|
1289
|
+
*/
|
|
1290
|
+
export function commerceConformanceAdaptiveSkipReason(vector, capabilities, set = [vector]) {
|
|
1291
|
+
return adaptiveSkipReasonWith(vector, capabilities, purchaseFinishFamilyOf(set));
|
|
1292
|
+
}
|
|
1293
|
+
function adaptiveSkipReasonWith(vector, capabilities, family) {
|
|
1294
|
+
const stepReason = stepSkipReason(vector, capabilities);
|
|
1295
|
+
if (stepReason !== undefined) {
|
|
1296
|
+
return stepReason;
|
|
1489
1297
|
}
|
|
1490
|
-
|
|
1298
|
+
if (purchaseFinishFamily.test(vector.vector)) {
|
|
1299
|
+
const owner = capabilities.lifecycle.finish;
|
|
1300
|
+
// The canonical member must itself be runnable here, so a provider whose
|
|
1301
|
+
// capabilities rule out the member named after its owner still runs one.
|
|
1302
|
+
const runnable = family.filter((member) => stepSkipReason(member, capabilities) === undefined);
|
|
1303
|
+
const named = runnable.find((member) => member.vector === `purchase-${owner}-finish`);
|
|
1304
|
+
const canonical = (named ?? runnable[0])?.vector;
|
|
1305
|
+
if (canonical !== undefined && canonical !== vector.vector) {
|
|
1306
|
+
return `duplicate of ${canonical} for finish='${owner}'`;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return undefined;
|
|
1310
|
+
}
|
|
1311
|
+
/** The step-level rules of `commerceConformanceAdaptiveSkipReason`, without the family rule. */
|
|
1312
|
+
function stepSkipReason(vector, capabilities) {
|
|
1313
|
+
const { lifecycle, purchase } = capabilities;
|
|
1314
|
+
const owner = lifecycle.finish;
|
|
1315
|
+
const catalog = vector.catalog ?? [];
|
|
1316
|
+
const steps = vector.steps;
|
|
1317
|
+
for (const [index, step] of steps.entries()) {
|
|
1318
|
+
if (step.actor === 'game' && step.action === 'requestPurchase') {
|
|
1319
|
+
const kind = catalog.find((entry) => entry.logicalId === step.input.logicalId)?.kind;
|
|
1320
|
+
if (kind !== undefined && !purchase[kind]) {
|
|
1321
|
+
return `requestPurchase of ${step.input.logicalId} needs purchase.${kind}; `
|
|
1322
|
+
+ `the provider declares ${kind}: false`;
|
|
1323
|
+
}
|
|
1324
|
+
const quantity = step.input.quantity ?? 1;
|
|
1325
|
+
if (quantity > 1 && !purchase.quantity) {
|
|
1326
|
+
return `requestPurchase of ${step.input.logicalId} with quantity ${String(quantity)} needs `
|
|
1327
|
+
+ 'purchase.quantity; the provider declares quantity: false';
|
|
1328
|
+
}
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
if (step.actor !== 'store') {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (!lifecycle.pending
|
|
1335
|
+
&& ((step.action === 'complete' || step.action === 'update') && step.purchase.state === 'pending'
|
|
1336
|
+
|| (step.action === 'queueRequest'
|
|
1337
|
+
&& step.result.outcome === 'purchased'
|
|
1338
|
+
&& step.result.purchase.state === 'pending'))) {
|
|
1339
|
+
return 'store-side pending purchase needs lifecycle.pending; the provider declares pending: false';
|
|
1340
|
+
}
|
|
1341
|
+
if (step.action === 'error' && !lifecycle.events) {
|
|
1342
|
+
return 'purchaseError event needs lifecycle.events; the provider declares events: false';
|
|
1343
|
+
}
|
|
1344
|
+
if ((step.action === 'complete' || step.action === 'update') && !lifecycle.events) {
|
|
1345
|
+
// Polling ingests the purchase at the next reconcile or start; a step in
|
|
1346
|
+
// between that would have observed the event-driven grant or finish
|
|
1347
|
+
// (another store script, a ledger write) makes the scenario timing-bound.
|
|
1348
|
+
const prefix = `store.${step.action} at steps[${String(index)}] needs lifecycle.events; `
|
|
1349
|
+
+ 'the provider declares events: false';
|
|
1350
|
+
if (!lifecycle.availablePurchases) {
|
|
1351
|
+
return `${prefix} and lifecycle.availablePurchases: false, so nothing can poll the purchase`;
|
|
1352
|
+
}
|
|
1353
|
+
let polled = false;
|
|
1354
|
+
for (const [offset, later] of steps.slice(index + 1).entries()) {
|
|
1355
|
+
if (later.actor === 'runtime') {
|
|
1356
|
+
if (later.action === 'stop') {
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
polled = true;
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
1362
|
+
if (later.actor === 'clock' || (later.actor === 'backend' && later.action === 'queueVerify')) {
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
return `${prefix} and ${later.actor}.${later.action} at steps[${String(index + 1 + offset)}] `
|
|
1366
|
+
+ 'runs before the reconcile or start that would poll the purchase';
|
|
1367
|
+
}
|
|
1368
|
+
if (!polled) {
|
|
1369
|
+
return `${prefix} and no reconcile or start follows it to poll the purchase`;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (step.action === 'queueFinish' && owner !== 'client' && owner !== 'host') {
|
|
1373
|
+
return `queueFinish scripts a client finish; the provider declares finish: '${owner}'`;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return undefined;
|
|
1377
|
+
}
|
|
1378
|
+
/** The adaptive `purchase-<owner>-finish` members of a run, sorted by name. */
|
|
1379
|
+
function purchaseFinishFamilyOf(set) {
|
|
1380
|
+
return set
|
|
1381
|
+
.filter((candidate) => candidate.profile.adaptive === true && purchaseFinishFamily.test(candidate.vector))
|
|
1382
|
+
.sort((left, right) => (left.vector < right.vector ? -1 : left.vector > right.vector ? 1 : 0));
|
|
1383
|
+
}
|
|
1384
|
+
function adaptationFor(vector, capabilities) {
|
|
1385
|
+
const fixtureOwner = createCommerceConformanceCapabilities(vector.profile).lifecycle.finish;
|
|
1386
|
+
return {
|
|
1387
|
+
serverFinalization: capabilities.lifecycle.finish === 'server' && fixtureOwner !== 'server',
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* The vector with every scripted store purchase (`complete`, `update`, and a
|
|
1392
|
+
* `queueRequest` answering `purchased`) carrying the token the store actor
|
|
1393
|
+
* reports for it (`CommerceConformanceStoreActor.tokenOf`).
|
|
1394
|
+
*/
|
|
1395
|
+
export function withStoreTokens(vector, tokenOf) {
|
|
1396
|
+
const withToken = (purchase) => ({ ...purchase, token: tokenOf(purchase) });
|
|
1397
|
+
return {
|
|
1398
|
+
...vector,
|
|
1399
|
+
steps: vector.steps.map((step) => {
|
|
1400
|
+
if (step.actor !== 'store') {
|
|
1401
|
+
return step;
|
|
1402
|
+
}
|
|
1403
|
+
if (step.action === 'complete' || step.action === 'update') {
|
|
1404
|
+
return { ...step, purchase: withToken(step.purchase) };
|
|
1405
|
+
}
|
|
1406
|
+
if (step.action === 'queueRequest' && step.result.outcome === 'purchased') {
|
|
1407
|
+
return { ...step, result: { ...step.result, purchase: withToken(step.result.purchase) } };
|
|
1408
|
+
}
|
|
1409
|
+
return step;
|
|
1410
|
+
}),
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
/** The vector with the provider's store id and declared capabilities as its profile. */
|
|
1414
|
+
function referenceVectorFor(vector, store, capabilities) {
|
|
1415
|
+
return {
|
|
1416
|
+
...vector,
|
|
1417
|
+
profile: {
|
|
1418
|
+
store,
|
|
1419
|
+
purchase: capabilities.purchase,
|
|
1420
|
+
lifecycle: capabilities.lifecycle,
|
|
1421
|
+
authority: capabilities.authority,
|
|
1422
|
+
adaptive: true,
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Derives the adaptive expectation for `vector` from the observable state of a
|
|
1428
|
+
* finished reference run under the provider's capabilities, applying the
|
|
1429
|
+
* rules the RFC table states:
|
|
1430
|
+
*
|
|
1431
|
+
* - `journal`: the reference journal, so the tail after `granted` is
|
|
1432
|
+
* `finishing, finished` for `client`, `host`, and `server` owners and
|
|
1433
|
+
* `finished` for `none`; derived keys carry the provider's store id;
|
|
1434
|
+
* - `finishCalls`: the reference store's calls, `by` the owner for `client`
|
|
1435
|
+
* and `host`, none for `server` and `none`;
|
|
1436
|
+
* - `verifyRequests`: the reference backend's log, with the finalization
|
|
1437
|
+
* re-read a `server` owner adds unless the grant already reported
|
|
1438
|
+
* `finalization: 'completed'`;
|
|
1439
|
+
* - `available`: what the reference store still lists once idle, so empty
|
|
1440
|
+
* after a completed finish for `client`, `host`, and `server` owners.
|
|
1441
|
+
*
|
|
1442
|
+
* Fields the fixture does not assert stay unasserted. The derivation is
|
|
1443
|
+
* refused (a skip reason) when the reference run does not end every journal
|
|
1444
|
+
* key in the fixture's terminal state, since the scenario then depends on a
|
|
1445
|
+
* capability the provider lacks.
|
|
1446
|
+
*/
|
|
1447
|
+
export async function deriveAdaptiveCommerceConformanceExpectation(vector, reference) {
|
|
1448
|
+
const { expect } = vector;
|
|
1449
|
+
if (expect.journal !== undefined) {
|
|
1450
|
+
const adapted = new Map();
|
|
1451
|
+
for (const fixtureKey of Object.keys(expect.journal)) {
|
|
1452
|
+
adapted.set(fixtureKey, await adaptDerivedPurchaseKey(vector, fixtureKey, reference.store));
|
|
1453
|
+
}
|
|
1454
|
+
for (const [fixtureKey, states] of Object.entries(expect.journal)) {
|
|
1455
|
+
const key = adapted.get(fixtureKey) ?? fixtureKey;
|
|
1456
|
+
const terminal = states.at(-1);
|
|
1457
|
+
const actual = reference.journal[key]?.at(-1);
|
|
1458
|
+
if (actual !== terminal) {
|
|
1459
|
+
return {
|
|
1460
|
+
reason: `the reference run under the provider capabilities ends ${key} in `
|
|
1461
|
+
+ `'${String(actual)}', not '${String(terminal)}'`,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const expectedKeys = new Set(adapted.values());
|
|
1466
|
+
const extra = Object.keys(reference.journal).filter((key) => !expectedKeys.has(key));
|
|
1467
|
+
if (extra.length > 0) {
|
|
1468
|
+
return {
|
|
1469
|
+
reason: `the reference run under the provider capabilities journals ${extra.join(', ')}, `
|
|
1470
|
+
+ 'which the vector does not expect',
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return {
|
|
1475
|
+
expect: {
|
|
1476
|
+
...expect,
|
|
1477
|
+
...(expect.journal === undefined ? {} : { journal: reference.journal }),
|
|
1478
|
+
...(expect.verifyRequests === undefined
|
|
1479
|
+
? {}
|
|
1480
|
+
: {
|
|
1481
|
+
verifyRequests: reference.verifyRequests.map((request) => ({
|
|
1482
|
+
idempotencyKey: request.idempotencyKey,
|
|
1483
|
+
store: request.store,
|
|
1484
|
+
transactionId: request.transactionId,
|
|
1485
|
+
token: request.token,
|
|
1486
|
+
})),
|
|
1487
|
+
}),
|
|
1488
|
+
...(expect.finishCalls === undefined ? {} : { finishCalls: reference.finishCalls }),
|
|
1489
|
+
...(expect.available === undefined
|
|
1490
|
+
? {}
|
|
1491
|
+
: {
|
|
1492
|
+
available: reference.available.map((purchase) => ({
|
|
1493
|
+
transactionId: purchase.transactionId,
|
|
1494
|
+
storeSku: purchase.storeSku,
|
|
1495
|
+
state: purchase.state,
|
|
1496
|
+
})),
|
|
1497
|
+
}),
|
|
1498
|
+
},
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* A fixture journal key as the provider's store id derives it: only the
|
|
1503
|
+
* encoded store segment of a `store:<store>:<transactionId>` key
|
|
1504
|
+
* (`createDerivedPurchaseIdempotencyKey`) is replaced, so a transaction id
|
|
1505
|
+
* that happens to contain the fixture store id is left alone. Game-chosen
|
|
1506
|
+
* keys and store-specific key forms carry no such prefix and pass through.
|
|
1507
|
+
*/
|
|
1508
|
+
export function adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore) {
|
|
1509
|
+
const fixturePrefix = `store:${encodeURIComponent(fixtureStore)}:`;
|
|
1510
|
+
return key.startsWith(fixturePrefix)
|
|
1511
|
+
? `store:${encodeURIComponent(providerStore)}:${key.slice(fixturePrefix.length)}`
|
|
1512
|
+
: key;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* A fixture journal key as the provider's store id derives it. When the key is
|
|
1516
|
+
* the derived key of a purchase the vector scripts under the fixture store, it
|
|
1517
|
+
* is re-derived with `createDerivedPurchaseIdempotencyKey` for the provider
|
|
1518
|
+
* store, which also covers the store-specific key forms (`google-play` token
|
|
1519
|
+
* keys, `apps-in-toss:purchase:<id>`); otherwise only the generic
|
|
1520
|
+
* `store:<store>:` prefix is swapped (`adaptDerivedPurchaseKeyStore`).
|
|
1521
|
+
*/
|
|
1522
|
+
export async function adaptDerivedPurchaseKey(vector, key, providerStore) {
|
|
1523
|
+
const fixtureStore = vector.profile.store;
|
|
1524
|
+
for (const purchase of scriptedPurchasesOf(vector)) {
|
|
1525
|
+
const derive = (store) => createDerivedPurchaseIdempotencyKey({
|
|
1526
|
+
store,
|
|
1527
|
+
transactionId: purchase.transactionId,
|
|
1528
|
+
token: purchase.token,
|
|
1529
|
+
});
|
|
1530
|
+
if (await derive(fixtureStore) === key) {
|
|
1531
|
+
return derive(providerStore);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore);
|
|
1535
|
+
}
|
|
1536
|
+
function scriptedPurchasesOf(vector) {
|
|
1537
|
+
return vector.steps.flatMap((step) => {
|
|
1538
|
+
if (step.actor !== 'store') {
|
|
1539
|
+
return [];
|
|
1540
|
+
}
|
|
1541
|
+
if (step.action === 'complete' || step.action === 'update') {
|
|
1542
|
+
return [step.purchase];
|
|
1543
|
+
}
|
|
1544
|
+
return step.action === 'queueRequest' && step.result.outcome === 'purchased'
|
|
1545
|
+
? [step.result.purchase]
|
|
1546
|
+
: [];
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
async function deriveAdaptiveExpectation(vector, reference, store) {
|
|
1550
|
+
return deriveAdaptiveCommerceConformanceExpectation(vector, {
|
|
1551
|
+
store,
|
|
1552
|
+
journal: reference.journal.snapshot(),
|
|
1553
|
+
verifyRequests: reference.backend.verifyRequests,
|
|
1554
|
+
finishCalls: reference.harness.store?.finishCalls ?? [],
|
|
1555
|
+
available: await reference.harness.provider.getAvailablePurchases({ includePending: true }),
|
|
1556
|
+
});
|
|
1491
1557
|
}
|
|
1492
1558
|
function storeActorOf(context, step) {
|
|
1493
1559
|
if (context.harness.store === undefined) {
|
|
@@ -1604,8 +1670,7 @@ async function runStep(step, path, context) {
|
|
|
1604
1670
|
return;
|
|
1605
1671
|
}
|
|
1606
1672
|
}
|
|
1607
|
-
async function matchExpectations(
|
|
1608
|
-
const { expect } = vector;
|
|
1673
|
+
async function matchExpectations(expect, context) {
|
|
1609
1674
|
const { failures, harness } = context;
|
|
1610
1675
|
if (expect.journal !== undefined) {
|
|
1611
1676
|
const actual = context.journal.snapshot();
|