@saasicat/persistence-testing 1.0.0-rc.13 → 1.0.0-rc.15

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/index.js CHANGED
@@ -2,12 +2,189 @@
2
2
  import assert from "node:assert/strict";
3
3
  import { after, before, beforeEach, describe, test } from "node:test";
4
4
  var LOCK_HOLD_MS = 150;
5
+ var OFFER = {
6
+ planKey: "STANDARD",
7
+ planVersionId: null,
8
+ billingCycle: "monthly",
9
+ priceBreakdown: {
10
+ currency: "EUR",
11
+ billingCycle: "monthly",
12
+ planNet: 49,
13
+ bundlesNet: 0,
14
+ regularNet: 49,
15
+ effectiveNet: 49,
16
+ vatRate: 19,
17
+ effectiveGross: 58.31
18
+ }
19
+ };
20
+ function contractFromOffer(offerId) {
21
+ return {
22
+ tenantId: `tenant-${offerId}`,
23
+ effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
24
+ originalOfferId: offerId,
25
+ priceSnapshot: {
26
+ currency: "EUR",
27
+ billingCycle: "monthly",
28
+ subtotalNet: 49,
29
+ discountNet: 0,
30
+ totalNet: 49,
31
+ vatRate: 19,
32
+ totalGross: 58.31
33
+ },
34
+ lineItems: [
35
+ {
36
+ kind: "plan",
37
+ sourceKey: "STANDARD",
38
+ sourceVersionId: null,
39
+ titleSnapshot: "Standard",
40
+ descriptionSnapshot: null,
41
+ quantity: 1,
42
+ unit: null,
43
+ priceNet: 49,
44
+ priceGross: 58.31,
45
+ billingCycle: "monthly",
46
+ currency: "EUR",
47
+ taxRate: 19,
48
+ taxAmount: 9.31,
49
+ minimumTermUntil: null,
50
+ featuresSnapshot: [],
51
+ quotaEffectsSnapshot: {},
52
+ metadata: null
53
+ }
54
+ ]
55
+ };
56
+ }
57
+ var CONTRACT_GAPS = {
58
+ atomicPlanBinding: {
59
+ reason: "adapter does not expose atomic plan-binding writes",
60
+ present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite)
61
+ },
62
+ atomicOnboarding: {
63
+ reason: "adapter does not expose atomic onboarding writes",
64
+ present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite?.applyOnboardingSelection)
65
+ },
66
+ promoCodes: {
67
+ reason: "adapter provides no PromoCodeRepository",
68
+ present: ({ adapter }) => Boolean(adapter.promoCodeRepository)
69
+ },
70
+ promoCodeRedemptions: {
71
+ reason: "adapter provides no PromoCodeRedemptionRepository",
72
+ present: ({ adapter }) => Boolean(adapter.promoCodeRedemptionRepository)
73
+ },
74
+ promoSubscriptionLookup: {
75
+ reason: "adapter provides no PromoSubscriptionLookup",
76
+ present: ({ adapter }) => Boolean(adapter.promoSubscriptionLookup)
77
+ },
78
+ planRepository: {
79
+ reason: "adapter provides no PlanRepository",
80
+ present: ({ adapter }) => Boolean(adapter.planRepository)
81
+ },
82
+ planLifecycle: {
83
+ reason: "adapter provides no time-aware PlanRepository lifecycle",
84
+ present: ({ adapter }) => {
85
+ const repository = adapter.planRepository;
86
+ return Boolean(
87
+ repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.findVersionById && repository.findActivePlanVersion
88
+ );
89
+ }
90
+ },
91
+ planRetirement: {
92
+ reason: "adapter provides no PlanRepository that retires and finds plans by key",
93
+ present: ({ adapter }) => Boolean(adapter.planRepository?.softDelete && adapter.planRepository.findByKey)
94
+ },
95
+ planVersionReads: {
96
+ reason: "adapter provides no PlanRepository that reads versions by plan key",
97
+ present: ({ adapter }) => {
98
+ const repository = adapter.planRepository;
99
+ return Boolean(
100
+ repository?.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion
101
+ );
102
+ }
103
+ },
104
+ planVersionRetirement: {
105
+ reason: "adapter provides no PlanRepository that reads versions and retires plans",
106
+ present: ({ adapter }) => {
107
+ const repository = adapter.planRepository;
108
+ return Boolean(
109
+ repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion && repository.softDelete
110
+ );
111
+ }
112
+ },
113
+ bundleRepository: {
114
+ reason: "adapter provides no BundleRepository",
115
+ present: ({ adapter }) => Boolean(adapter.bundleRepository)
116
+ },
117
+ bundleValidity: {
118
+ reason: "adapter provides no time-aware BundleRepository",
119
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.findActiveBundleVersion)
120
+ },
121
+ bundleDraftDiscard: {
122
+ reason: "adapter provides no BundleRepository that discards drafts",
123
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.deleteDraft)
124
+ },
125
+ bundleDraftPublish: {
126
+ reason: "adapter provides no BundleRepository that publishes drafts",
127
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.publishDraft)
128
+ },
129
+ bundleRetirement: {
130
+ reason: "adapter provides no BundleRepository that retires and finds bundles by key",
131
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.softDelete && adapter.bundleRepository.findByKey)
132
+ },
133
+ bundleBookings: {
134
+ reason: "adapter provides no SubscriptionBundleRepository or bundle catalog",
135
+ present: ({ adapter, seed }) => Boolean(adapter.subscriptionBundleRepository && seed.createBundleVersion)
136
+ },
137
+ halfCancelledBookingSeed: {
138
+ reason: "adapter harness cannot write the half-cancelled shape",
139
+ present: ({ seed }) => Boolean(seed.clearBookingRequestDate)
140
+ },
141
+ countByPlanVersionId: {
142
+ reason: "adapter does not implement countByPlanVersionId (fail-closed fallback)",
143
+ present: ({ adapter }) => Boolean(adapter.subscriptionRepository.countByPlanVersionId)
144
+ },
145
+ audit: {
146
+ reason: "adapter provides no AuditPort/AuditQueryPort pair",
147
+ present: ({ adapter }) => Boolean(adapter.audit && adapter.auditQuery)
148
+ },
149
+ mfa: {
150
+ reason: "adapter provides no MfaPort",
151
+ present: ({ adapter }) => Boolean(adapter.mfa)
152
+ },
153
+ subscriptionContracts: {
154
+ reason: "adapter provides no SubscriptionContractRepository",
155
+ present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
156
+ },
157
+ checkoutOffers: {
158
+ reason: "adapter provides no CheckoutOfferRepository",
159
+ present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
160
+ },
161
+ appliedSettings: {
162
+ reason: "adapter provides no AppliedSettingsPort",
163
+ present: ({ adapter }) => Boolean(adapter.appliedSettings)
164
+ }
165
+ };
5
166
  function sleep(ms) {
6
167
  return new Promise((resolve) => setTimeout(resolve, ms));
7
168
  }
8
169
  function persistenceAdapterContract(options) {
170
+ const declaredGaps = new Set(options.gaps ?? []);
171
+ let harness;
172
+ function missing(t, gap) {
173
+ const { reason, present } = CONTRACT_GAPS[gap];
174
+ if (present(harness)) {
175
+ assert.fail(
176
+ `'${gap}' counts as provided, yet this scenario found a member it needs missing or not callable. Check that the harness wires the port itself; if it does, the contract's check for '${gap}' and this scenario disagree, which is a defect in @saasicat/persistence-testing.`
177
+ );
178
+ }
179
+ if (declaredGaps.has(gap)) {
180
+ t.skip(reason);
181
+ return;
182
+ }
183
+ assert.fail(
184
+ `${reason}. Wire it into the harness, or declare \`gaps: ['${gap}']\` in persistenceAdapterContract if the adapter deliberately does not provide it.`
185
+ );
186
+ }
9
187
  describe(`persistence adapter contract: ${options.name}`, () => {
10
- let harness;
11
188
  before(async () => {
12
189
  harness = await options.create();
13
190
  });
@@ -17,6 +194,20 @@ function persistenceAdapterContract(options) {
17
194
  beforeEach(async () => {
18
195
  await harness.reset();
19
196
  });
197
+ test("the declared gaps are exactly the parts the harness does not provide", () => {
198
+ const gaps = Object.keys(CONTRACT_GAPS);
199
+ const absent = gaps.filter((gap) => !CONTRACT_GAPS[gap].present(harness));
200
+ const known = (gap) => Object.prototype.hasOwnProperty.call(CONTRACT_GAPS, gap);
201
+ const unknown = [...declaredGaps].filter((gap) => !known(gap));
202
+ const stale = [...declaredGaps].filter((gap) => known(gap) && !absent.includes(gap));
203
+ const undeclared = absent.filter((gap) => !declaredGaps.has(gap));
204
+ const problems = [
205
+ unknown.length > 0 && `declared as gaps but not parts of the contract: ${unknown.join(", ")} (ContractGap lists the names)`,
206
+ stale.length > 0 && `declared as gaps but wired into the harness: ${stale.join(", ")}`,
207
+ undeclared.length > 0 && `not wired into the harness and not declared as gaps: ${undeclared.join(", ")}`
208
+ ].filter(Boolean);
209
+ assert.deepEqual(problems, [], problems.join("; "));
210
+ });
20
211
  test("findByTenantId returns the tenant subscription with plan-version limits", async () => {
21
212
  const { seed, adapter } = harness;
22
213
  const { planVersionId } = await seed.createPlanVersion({
@@ -91,7 +282,7 @@ function persistenceAdapterContract(options) {
91
282
  test("immediate plan change binds plan and active PlanVersion consistently", async (t) => {
92
283
  const { seed, adapter } = harness;
93
284
  if (!adapter.tenantSubscriptionWrite) {
94
- t.skip("adapter does not expose atomic plan-binding writes");
285
+ missing(t, "atomicPlanBinding");
95
286
  return;
96
287
  }
97
288
  const oldVersion = await seed.createPlanVersion({
@@ -136,12 +327,12 @@ function persistenceAdapterContract(options) {
136
327
  const { seed, adapter } = harness;
137
328
  const writer = adapter.tenantSubscriptionWrite;
138
329
  if (!writer?.applyOnboardingSelection) {
139
- t.skip("adapter does not expose atomic onboarding writes");
330
+ missing(t, "atomicOnboarding");
140
331
  return;
141
332
  }
142
333
  const redemptions = adapter.promoCodeRedemptionRepository;
143
334
  if (!redemptions) {
144
- t.skip("adapter provides no PromoCodeRedemptionRepository");
335
+ missing(t, "promoCodeRedemptions");
145
336
  return;
146
337
  }
147
338
  const oldVersion = await seed.createPlanVersion({
@@ -220,7 +411,7 @@ function persistenceAdapterContract(options) {
220
411
  test("plan lifecycle keeps semantic identity and auto-succeeds validity windows", async (t) => {
221
412
  const repository = harness.adapter.planRepository;
222
413
  if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.findVersionById || !repository.findActivePlanVersion) {
223
- t.skip("adapter provides no time-aware PlanRepository lifecycle");
414
+ missing(t, "planLifecycle");
224
415
  return;
225
416
  }
226
417
  const plan = await repository.create({
@@ -284,7 +475,7 @@ function persistenceAdapterContract(options) {
284
475
  test("bundle lifecycle roundtrips validity and auto-succeeds atomically", async (t) => {
285
476
  const repository = harness.adapter.bundleRepository;
286
477
  if (!repository?.findActiveBundleVersion) {
287
- t.skip("adapter provides no time-aware BundleRepository");
478
+ missing(t, "bundleValidity");
288
479
  return;
289
480
  }
290
481
  const bundle = await repository.create({
@@ -343,7 +534,7 @@ function persistenceAdapterContract(options) {
343
534
  const repository = harness.adapter.subscriptionBundleRepository;
344
535
  const { seed } = harness;
345
536
  if (!repository || !seed.createBundleVersion) {
346
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
537
+ missing(t, "bundleBookings");
347
538
  return;
348
539
  }
349
540
  const { planVersionId } = await seed.createPlanVersion({
@@ -398,7 +589,7 @@ function persistenceAdapterContract(options) {
398
589
  const repository = harness.adapter.subscriptionBundleRepository;
399
590
  const { seed } = harness;
400
591
  if (!repository || !seed.createBundleVersion) {
401
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
592
+ missing(t, "bundleBookings");
402
593
  return;
403
594
  }
404
595
  const { planVersionId } = await seed.createPlanVersion({
@@ -452,7 +643,7 @@ function persistenceAdapterContract(options) {
452
643
  const repository = harness.adapter.subscriptionBundleRepository;
453
644
  const { seed } = harness;
454
645
  if (!repository || !seed.createBundleVersion) {
455
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
646
+ missing(t, "bundleBookings");
456
647
  return;
457
648
  }
458
649
  const { planVersionId } = await seed.createPlanVersion({
@@ -497,7 +688,7 @@ function persistenceAdapterContract(options) {
497
688
  const repository = harness.adapter.subscriptionBundleRepository;
498
689
  const { seed } = harness;
499
690
  if (!repository || !seed.createBundleVersion) {
500
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
691
+ missing(t, "bundleBookings");
501
692
  return;
502
693
  }
503
694
  const { planVersionId } = await seed.createPlanVersion({
@@ -528,7 +719,7 @@ function persistenceAdapterContract(options) {
528
719
  });
529
720
  const clearRequestDate = harness.seed.clearBookingRequestDate;
530
721
  if (!clearRequestDate) {
531
- t.skip("adapter harness cannot write the half-cancelled shape");
722
+ missing(t, "halfCancelledBookingSeed");
532
723
  return;
533
724
  }
534
725
  await clearRequestDate(booking.id);
@@ -549,7 +740,7 @@ function persistenceAdapterContract(options) {
549
740
  const catalog = harness.adapter.bundleRepository;
550
741
  const discardDraft = catalog?.deleteDraft?.bind(catalog);
551
742
  if (!catalog || !discardDraft) {
552
- t.skip("adapter provides no BundleRepository");
743
+ missing(t, "bundleDraftDiscard");
553
744
  return;
554
745
  }
555
746
  const bundle = await catalog.create({
@@ -581,7 +772,7 @@ function persistenceAdapterContract(options) {
581
772
  const catalog = harness.adapter.bundleRepository;
582
773
  const publish = catalog?.publishDraft?.bind(catalog);
583
774
  if (!catalog || !publish) {
584
- t.skip("adapter provides no BundleRepository");
775
+ missing(t, "bundleDraftPublish");
585
776
  return;
586
777
  }
587
778
  const bundle = await catalog.create({
@@ -644,7 +835,7 @@ function persistenceAdapterContract(options) {
644
835
  test("a plan key names one plan for the whole installation", async (t) => {
645
836
  const repository = harness.adapter.planRepository;
646
837
  if (!repository) {
647
- t.skip("adapter provides no PlanRepository");
838
+ missing(t, "planRepository");
648
839
  return;
649
840
  }
650
841
  await repository.create({ planKey: "DOUBLE", label: "First" });
@@ -656,7 +847,7 @@ function persistenceAdapterContract(options) {
656
847
  test("a bundle key names one bundle for the whole installation", async (t) => {
657
848
  const catalog = harness.adapter.bundleRepository;
658
849
  if (!catalog) {
659
- t.skip("adapter provides no BundleRepository");
850
+ missing(t, "bundleRepository");
660
851
  return;
661
852
  }
662
853
  await catalog.create({ bundleKey: "DOUBLE", label: "First" });
@@ -670,7 +861,7 @@ function persistenceAdapterContract(options) {
670
861
  const retire = repository?.softDelete?.bind(repository);
671
862
  const byKey = repository?.findByKey?.bind(repository);
672
863
  if (!repository || !retire || !byKey) {
673
- t.skip("adapter provides no PlanRepository");
864
+ missing(t, "planRetirement");
674
865
  return;
675
866
  }
676
867
  const plan = await repository.create({ planKey: "RETIRED_PLAN", label: "Retired" });
@@ -684,12 +875,90 @@ function persistenceAdapterContract(options) {
684
875
  "a retired plan is not in the catalogue an operator browses"
685
876
  );
686
877
  });
878
+ test("a plan key no plan has finds no versions, rather than failing", async (t) => {
879
+ const repository = harness.adapter.planRepository;
880
+ if (!repository?.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion) {
881
+ missing(t, "planVersionReads");
882
+ return;
883
+ }
884
+ const entitlementVersions = harness.adapter.planVersionRepository;
885
+ assert.deepEqual(await repository.listVersions("NO_SUCH_PLAN"), []);
886
+ assert.equal(await repository.findCurrentDraft("NO_SUCH_PLAN"), null);
887
+ assert.equal(await repository.findLatestLivePlanVersion("NO_SUCH_PLAN"), null);
888
+ if (repository.findActivePlanVersion) {
889
+ assert.equal(
890
+ await repository.findActivePlanVersion("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
891
+ null
892
+ );
893
+ }
894
+ assert.equal(await entitlementVersions.findLatestLive("NO_SUCH_PLAN"), null);
895
+ if (entitlementVersions.findActive) {
896
+ assert.equal(
897
+ await entitlementVersions.findActive("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
898
+ null
899
+ );
900
+ }
901
+ });
902
+ test("retiring a plan hides none of its versions", async (t) => {
903
+ const repository = harness.adapter.planRepository;
904
+ if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion || !repository.softDelete) {
905
+ missing(t, "planVersionRetirement");
906
+ return;
907
+ }
908
+ const listVersions = repository.listVersions.bind(repository);
909
+ const findCurrentDraft = repository.findCurrentDraft.bind(repository);
910
+ const findLatestLive = repository.findLatestLivePlanVersion.bind(repository);
911
+ const entitlementVersions = harness.adapter.planVersionRepository;
912
+ const plan = await repository.create({ planKey: "RETIRING", label: "Retiring" });
913
+ const firstDraft = await repository.createPlanVersionDraft({
914
+ planId: "RETIRING",
915
+ features: ["CORE"],
916
+ quotas: { users: 5 },
917
+ monthlyNet: "10.00",
918
+ yearlyNet: "100.00",
919
+ validFrom: "2026-01-01"
920
+ });
921
+ const live = await repository.publishPlanVersionDraft(firstDraft.id, {
922
+ publishedByUserId: null,
923
+ publishedChanges: [],
924
+ nonRegressive: true,
925
+ validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
926
+ validUntil: null
927
+ });
928
+ const openDraft = await repository.createPlanVersionDraft({
929
+ planId: "RETIRING",
930
+ baseVersionId: live.id,
931
+ features: ["CORE", "PLUS"],
932
+ quotas: { users: 10 },
933
+ monthlyNet: "15.00",
934
+ yearlyNet: "150.00",
935
+ validFrom: "2026-06-01"
936
+ });
937
+ const reads = async () => ({
938
+ versions: (await listVersions("RETIRING")).map((row) => row.id),
939
+ draft: (await findCurrentDraft("RETIRING"))?.id ?? null,
940
+ latestLive: (await findLatestLive("RETIRING"))?.id ?? null,
941
+ entitlementFeatures: (await entitlementVersions.findLatestLive("RETIRING"))?.features ?? null
942
+ });
943
+ const beforeRetiring = await reads();
944
+ assert.deepEqual(
945
+ {
946
+ versions: beforeRetiring.versions,
947
+ draft: beforeRetiring.draft,
948
+ latestLive: beforeRetiring.latestLive
949
+ },
950
+ { versions: [live.id, openDraft.id], draft: openDraft.id, latestLive: live.id },
951
+ "a live plan reads its versions, so the comparison below has a subject"
952
+ );
953
+ await repository.softDelete(plan.id);
954
+ assert.deepEqual(await reads(), beforeRetiring, "retiring the plan hid its versions");
955
+ });
687
956
  test("a retired bundle still occupies its key", async (t) => {
688
957
  const catalog = harness.adapter.bundleRepository;
689
958
  const retire = catalog?.softDelete?.bind(catalog);
690
959
  const byKey = catalog?.findByKey?.bind(catalog);
691
960
  if (!catalog || !retire || !byKey) {
692
- t.skip("adapter provides no BundleRepository");
961
+ missing(t, "bundleRetirement");
693
962
  return;
694
963
  }
695
964
  const bundle = await catalog.create({
@@ -711,7 +980,7 @@ function persistenceAdapterContract(options) {
711
980
  test("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
712
981
  const { seed, adapter } = harness;
713
982
  if (!adapter.subscriptionRepository.countByPlanVersionId) {
714
- t.skip("adapter does not implement countByPlanVersionId (fail-closed fallback)");
983
+ missing(t, "countByPlanVersionId");
715
984
  return;
716
985
  }
717
986
  const v1 = await seed.createPlanVersion({
@@ -751,9 +1020,7 @@ function persistenceAdapterContract(options) {
751
1020
  return;
752
1021
  }
753
1022
  if (!adapter.promoCodeRedemptionRepository) {
754
- t.skip(
755
- "adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)"
756
- );
1023
+ missing(t, "promoCodeRedemptions");
757
1024
  return;
758
1025
  }
759
1026
  const redemptions = adapter.promoCodeRedemptionRepository;
@@ -836,7 +1103,7 @@ function persistenceAdapterContract(options) {
836
1103
  test("concurrent claimSlot grants exactly maxRedemptions slots", async (t) => {
837
1104
  const { seed, adapter } = harness;
838
1105
  if (!adapter.promoCodeRepository) {
839
- t.skip("adapter provides no PromoCodeRepository");
1106
+ missing(t, "promoCodes");
840
1107
  return;
841
1108
  }
842
1109
  const promoCodes = adapter.promoCodeRepository;
@@ -857,7 +1124,7 @@ function persistenceAdapterContract(options) {
857
1124
  test("claimSlot / markExhaustedIfFull / releaseSlot lifecycle", async (t) => {
858
1125
  const { seed, adapter } = harness;
859
1126
  if (!adapter.promoCodeRepository) {
860
- t.skip("adapter provides no PromoCodeRepository");
1127
+ missing(t, "promoCodes");
861
1128
  return;
862
1129
  }
863
1130
  const promoCodes = adapter.promoCodeRepository;
@@ -877,7 +1144,7 @@ function persistenceAdapterContract(options) {
877
1144
  test("a subscription cannot redeem twice (unique guard)", async (t) => {
878
1145
  const { seed, adapter } = harness;
879
1146
  if (!adapter.promoCodeRedemptionRepository) {
880
- t.skip("adapter provides no PromoCodeRedemptionRepository");
1147
+ missing(t, "promoCodeRedemptions");
881
1148
  return;
882
1149
  }
883
1150
  const redemptions = adapter.promoCodeRedemptionRepository;
@@ -917,7 +1184,7 @@ function persistenceAdapterContract(options) {
917
1184
  test("audit write \u2192 query roundtrip with actorTag filters", async (t) => {
918
1185
  const { adapter } = harness;
919
1186
  if (!adapter.audit || !adapter.auditQuery) {
920
- t.skip("adapter provides no AuditPort/AuditQueryPort pair");
1187
+ missing(t, "audit");
921
1188
  return;
922
1189
  }
923
1190
  await adapter.audit.write({
@@ -957,7 +1224,7 @@ function persistenceAdapterContract(options) {
957
1224
  test("MFA secret roundtrip", async (t) => {
958
1225
  const { adapter } = harness;
959
1226
  if (!adapter.mfa) {
960
- t.skip("adapter provides no MfaPort");
1227
+ missing(t, "mfa");
961
1228
  return;
962
1229
  }
963
1230
  assert.equal(await adapter.mfa.getSecret("admin-1"), null);
@@ -972,7 +1239,7 @@ function persistenceAdapterContract(options) {
972
1239
  test("finds the subscription the id names, not merely a subscription", async (t) => {
973
1240
  const { adapter, seed } = harness;
974
1241
  if (!adapter.promoSubscriptionLookup) {
975
- t.skip("adapter provides no PromoSubscriptionLookup");
1242
+ missing(t, "promoSubscriptionLookup");
976
1243
  return;
977
1244
  }
978
1245
  const { planVersionId } = await seed.createPlanVersion({
@@ -1001,7 +1268,7 @@ function persistenceAdapterContract(options) {
1001
1268
  test("returns null for an id that does not exist", async (t) => {
1002
1269
  const { adapter, seed } = harness;
1003
1270
  if (!adapter.promoSubscriptionLookup) {
1004
- t.skip("adapter provides no PromoSubscriptionLookup");
1271
+ missing(t, "promoSubscriptionLookup");
1005
1272
  return;
1006
1273
  }
1007
1274
  const { planVersionId } = await seed.createPlanVersion({
@@ -1022,7 +1289,7 @@ function persistenceAdapterContract(options) {
1022
1289
  test("carries the fields a promo rule reads: cycle and start date", async (t) => {
1023
1290
  const { adapter, seed } = harness;
1024
1291
  if (!adapter.promoSubscriptionLookup) {
1025
- t.skip("adapter provides no PromoSubscriptionLookup");
1292
+ missing(t, "promoSubscriptionLookup");
1026
1293
  return;
1027
1294
  }
1028
1295
  const { planVersionId } = await seed.createPlanVersion({
@@ -1057,7 +1324,7 @@ function persistenceAdapterContract(options) {
1057
1324
  test("reads inside a transaction, so validation and redemption agree", async (t) => {
1058
1325
  const { adapter, seed } = harness;
1059
1326
  if (!adapter.promoSubscriptionLookup) {
1060
- t.skip("adapter provides no PromoSubscriptionLookup");
1327
+ missing(t, "promoSubscriptionLookup");
1061
1328
  return;
1062
1329
  }
1063
1330
  const { planVersionId } = await seed.createPlanVersion({
@@ -1081,7 +1348,7 @@ function persistenceAdapterContract(options) {
1081
1348
  test("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
1082
1349
  const contracts = harness.adapter.subscriptionContractRepository;
1083
1350
  if (!contracts) {
1084
- t.skip("adapter provides no SubscriptionContractRepository");
1351
+ missing(t, "subscriptionContracts");
1085
1352
  return;
1086
1353
  }
1087
1354
  const tenantId = "tenant-contract-lifecycle";
@@ -1237,7 +1504,7 @@ function persistenceAdapterContract(options) {
1237
1504
  test("a successor takes over without erasing the contract it replaces", async (t) => {
1238
1505
  const contracts = harness.adapter.subscriptionContractRepository;
1239
1506
  if (!contracts) {
1240
- t.skip("adapter provides no SubscriptionContractRepository");
1507
+ missing(t, "subscriptionContracts");
1241
1508
  return;
1242
1509
  }
1243
1510
  const tenantId = "tenant-contract-succession";
@@ -1339,7 +1606,7 @@ function persistenceAdapterContract(options) {
1339
1606
  test("a line keeps the currency and the tax it was booked with", async (t) => {
1340
1607
  const contracts = harness.adapter.subscriptionContractRepository;
1341
1608
  if (!contracts) {
1342
- t.skip("adapter provides no SubscriptionContractRepository");
1609
+ missing(t, "subscriptionContracts");
1343
1610
  return;
1344
1611
  }
1345
1612
  const tenantId = "tenant-contract-money-facts";
@@ -1418,6 +1685,86 @@ function persistenceAdapterContract(options) {
1418
1685
  );
1419
1686
  }
1420
1687
  });
1688
+ test("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
1689
+ const { adapter } = harness;
1690
+ const contracts = adapter.subscriptionContractRepository;
1691
+ if (!contracts) {
1692
+ missing(t, "subscriptionContracts");
1693
+ return;
1694
+ }
1695
+ if (!adapter.capabilities.transactions) {
1696
+ t.skip("adapter declares no transaction capability");
1697
+ return;
1698
+ }
1699
+ await assert.rejects(
1700
+ adapter.transactionRunner.run(async (tx) => {
1701
+ await contracts.create(contractFromOffer("offer-rolled-back"), tx);
1702
+ throw new Error("the conclusion fails after the contract is written");
1703
+ })
1704
+ );
1705
+ assert.equal(
1706
+ await contracts.findByOriginalOfferId("offer-rolled-back"),
1707
+ null,
1708
+ "the contract outlived its transaction"
1709
+ );
1710
+ const kept = await adapter.transactionRunner.run(
1711
+ (tx) => contracts.create(contractFromOffer("offer-kept"), tx)
1712
+ );
1713
+ const found = await contracts.findByOriginalOfferId("offer-kept");
1714
+ assert.equal(found?.id, kept.id);
1715
+ assert.equal(found?.lineItems.length, 1, "with its lines");
1716
+ assert.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
1717
+ });
1718
+ test("an offer is consumed once, whoever asks first", async (t) => {
1719
+ const offers = harness.adapter.checkoutOfferRepository;
1720
+ if (!offers) {
1721
+ missing(t, "checkoutOffers");
1722
+ return;
1723
+ }
1724
+ const offer = await offers.create(OFFER);
1725
+ assert.equal(offer.status, "open");
1726
+ const attempts = await Promise.allSettled([
1727
+ offers.consume(offer.id),
1728
+ offers.consume(offer.id)
1729
+ ]);
1730
+ assert.equal(
1731
+ attempts.filter((attempt) => attempt.status === "fulfilled").length,
1732
+ 1,
1733
+ "exactly one consume wins"
1734
+ );
1735
+ const consumed = await offers.findById(offer.id);
1736
+ assert.equal(consumed?.status, "consumed");
1737
+ assert.ok(consumed?.consumedAt, "and it records when");
1738
+ await assert.rejects(() => offers.consume(offer.id), "a later consume is refused");
1739
+ assert.equal(
1740
+ (await offers.findById(offer.id))?.consumedAt,
1741
+ consumed.consumedAt,
1742
+ "and changes nothing"
1743
+ );
1744
+ });
1745
+ test("a consume on a transaction that rolls back leaves the offer open", async (t) => {
1746
+ const { adapter } = harness;
1747
+ const offers = adapter.checkoutOfferRepository;
1748
+ if (!offers) {
1749
+ missing(t, "checkoutOffers");
1750
+ return;
1751
+ }
1752
+ if (!adapter.capabilities.transactions) {
1753
+ t.skip("adapter declares no transaction capability");
1754
+ return;
1755
+ }
1756
+ const offer = await offers.create(OFFER);
1757
+ await assert.rejects(
1758
+ adapter.transactionRunner.run(async (tx) => {
1759
+ await offers.consume(offer.id, tx);
1760
+ throw new Error("the contract after the consume fails");
1761
+ })
1762
+ );
1763
+ const afterwards = await offers.findById(offer.id);
1764
+ assert.equal(afterwards?.status, "open", "the consume outlived its transaction");
1765
+ assert.equal(afterwards?.consumedAt, null);
1766
+ assert.equal((await offers.consume(offer.id)).status, "consumed");
1767
+ });
1421
1768
  const SETTINGS = {
1422
1769
  app: { name: "Demo" },
1423
1770
  currency: "EUR",
@@ -1437,7 +1784,7 @@ function persistenceAdapterContract(options) {
1437
1784
  test("no record before the first boot that could write one", async (t) => {
1438
1785
  const port = harness.adapter.appliedSettings;
1439
1786
  if (!port) {
1440
- t.skip("adapter provides no AppliedSettingsPort");
1787
+ missing(t, "appliedSettings");
1441
1788
  return;
1442
1789
  }
1443
1790
  assert.equal(await port.readApplied(), null);
@@ -1446,7 +1793,7 @@ function persistenceAdapterContract(options) {
1446
1793
  test("the record comes back as it was written \u2014 values, source and moment", async (t) => {
1447
1794
  const port = harness.adapter.appliedSettings;
1448
1795
  if (!port) {
1449
- t.skip("adapter provides no AppliedSettingsPort");
1796
+ missing(t, "appliedSettings");
1450
1797
  return;
1451
1798
  }
1452
1799
  assert.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
@@ -1460,7 +1807,7 @@ function persistenceAdapterContract(options) {
1460
1807
  test("writing again replaces the one row rather than adding a second", async (t) => {
1461
1808
  const port = harness.adapter.appliedSettings;
1462
1809
  if (!port) {
1463
- t.skip("adapter provides no AppliedSettingsPort");
1810
+ missing(t, "appliedSettings");
1464
1811
  return;
1465
1812
  }
1466
1813
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1476,7 +1823,7 @@ function persistenceAdapterContract(options) {
1476
1823
  test("the first record is written once: a second writer that read none is refused", async (t) => {
1477
1824
  const port = harness.adapter.appliedSettings;
1478
1825
  if (!port) {
1479
- t.skip("adapter provides no AppliedSettingsPort");
1826
+ missing(t, "appliedSettings");
1480
1827
  return;
1481
1828
  }
1482
1829
  assert.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
@@ -1486,7 +1833,7 @@ function persistenceAdapterContract(options) {
1486
1833
  test("a write guarded on a fingerprint the row no longer carries is refused", async (t) => {
1487
1834
  const port = harness.adapter.appliedSettings;
1488
1835
  if (!port) {
1489
- t.skip("adapter provides no AppliedSettingsPort");
1836
+ missing(t, "appliedSettings");
1490
1837
  return;
1491
1838
  }
1492
1839
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1509,7 +1856,7 @@ function persistenceAdapterContract(options) {
1509
1856
  test("a change lands with the record it supersedes, and is listed newest first", async (t) => {
1510
1857
  const port = harness.adapter.appliedSettings;
1511
1858
  if (!port) {
1512
- t.skip("adapter provides no AppliedSettingsPort");
1859
+ missing(t, "appliedSettings");
1513
1860
  return;
1514
1861
  }
1515
1862
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1547,7 +1894,7 @@ function persistenceAdapterContract(options) {
1547
1894
  test("a change whose record has moved on is refused whole: no change, and the record as it was", async (t) => {
1548
1895
  const port = harness.adapter.appliedSettings;
1549
1896
  if (!port) {
1550
- t.skip("adapter provides no AppliedSettingsPort");
1897
+ missing(t, "appliedSettings");
1551
1898
  return;
1552
1899
  }
1553
1900
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1565,7 +1912,7 @@ function persistenceAdapterContract(options) {
1565
1912
  test("starts noticing the same difference at once record it once", async (t) => {
1566
1913
  const port = harness.adapter.appliedSettings;
1567
1914
  if (!port) {
1568
- t.skip("adapter provides no AppliedSettingsPort");
1915
+ missing(t, "appliedSettings");
1569
1916
  return;
1570
1917
  }
1571
1918
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1589,7 +1936,7 @@ function persistenceAdapterContract(options) {
1589
1936
  test("several first starts write the record once", async (t) => {
1590
1937
  const port = harness.adapter.appliedSettings;
1591
1938
  if (!port) {
1592
- t.skip("adapter provides no AppliedSettingsPort");
1939
+ missing(t, "appliedSettings");
1593
1940
  return;
1594
1941
  }
1595
1942
  const written = await Promise.all(
@@ -1601,7 +1948,7 @@ function persistenceAdapterContract(options) {
1601
1948
  test("changes are listed in the order they were recorded, latest first \u2014 not by the moment they carry", async (t) => {
1602
1949
  const port = harness.adapter.appliedSettings;
1603
1950
  if (!port) {
1604
- t.skip("adapter provides no AppliedSettingsPort");
1951
+ missing(t, "appliedSettings");
1605
1952
  return;
1606
1953
  }
1607
1954
  await port.writeApplied(applied("sha256-0", FIRST_START, {}), null);
@@ -1633,7 +1980,7 @@ function persistenceAdapterContract(options) {
1633
1980
  test("acknowledging a change is recorded once, and filters it out of what is owed", async (t) => {
1634
1981
  const port = harness.adapter.appliedSettings;
1635
1982
  if (!port) {
1636
- t.skip("adapter provides no AppliedSettingsPort");
1983
+ missing(t, "appliedSettings");
1637
1984
  return;
1638
1985
  }
1639
1986
  await port.writeApplied(applied("sha256-a", FIRST_START), null);