@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.cjs CHANGED
@@ -38,12 +38,189 @@ module.exports = __toCommonJS(index_exports);
38
38
  var import_strict = __toESM(require("node:assert/strict"), 1);
39
39
  var import_node_test = require("node:test");
40
40
  var LOCK_HOLD_MS = 150;
41
+ var OFFER = {
42
+ planKey: "STANDARD",
43
+ planVersionId: null,
44
+ billingCycle: "monthly",
45
+ priceBreakdown: {
46
+ currency: "EUR",
47
+ billingCycle: "monthly",
48
+ planNet: 49,
49
+ bundlesNet: 0,
50
+ regularNet: 49,
51
+ effectiveNet: 49,
52
+ vatRate: 19,
53
+ effectiveGross: 58.31
54
+ }
55
+ };
56
+ function contractFromOffer(offerId) {
57
+ return {
58
+ tenantId: `tenant-${offerId}`,
59
+ effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
60
+ originalOfferId: offerId,
61
+ priceSnapshot: {
62
+ currency: "EUR",
63
+ billingCycle: "monthly",
64
+ subtotalNet: 49,
65
+ discountNet: 0,
66
+ totalNet: 49,
67
+ vatRate: 19,
68
+ totalGross: 58.31
69
+ },
70
+ lineItems: [
71
+ {
72
+ kind: "plan",
73
+ sourceKey: "STANDARD",
74
+ sourceVersionId: null,
75
+ titleSnapshot: "Standard",
76
+ descriptionSnapshot: null,
77
+ quantity: 1,
78
+ unit: null,
79
+ priceNet: 49,
80
+ priceGross: 58.31,
81
+ billingCycle: "monthly",
82
+ currency: "EUR",
83
+ taxRate: 19,
84
+ taxAmount: 9.31,
85
+ minimumTermUntil: null,
86
+ featuresSnapshot: [],
87
+ quotaEffectsSnapshot: {},
88
+ metadata: null
89
+ }
90
+ ]
91
+ };
92
+ }
93
+ var CONTRACT_GAPS = {
94
+ atomicPlanBinding: {
95
+ reason: "adapter does not expose atomic plan-binding writes",
96
+ present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite)
97
+ },
98
+ atomicOnboarding: {
99
+ reason: "adapter does not expose atomic onboarding writes",
100
+ present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite?.applyOnboardingSelection)
101
+ },
102
+ promoCodes: {
103
+ reason: "adapter provides no PromoCodeRepository",
104
+ present: ({ adapter }) => Boolean(adapter.promoCodeRepository)
105
+ },
106
+ promoCodeRedemptions: {
107
+ reason: "adapter provides no PromoCodeRedemptionRepository",
108
+ present: ({ adapter }) => Boolean(adapter.promoCodeRedemptionRepository)
109
+ },
110
+ promoSubscriptionLookup: {
111
+ reason: "adapter provides no PromoSubscriptionLookup",
112
+ present: ({ adapter }) => Boolean(adapter.promoSubscriptionLookup)
113
+ },
114
+ planRepository: {
115
+ reason: "adapter provides no PlanRepository",
116
+ present: ({ adapter }) => Boolean(adapter.planRepository)
117
+ },
118
+ planLifecycle: {
119
+ reason: "adapter provides no time-aware PlanRepository lifecycle",
120
+ present: ({ adapter }) => {
121
+ const repository = adapter.planRepository;
122
+ return Boolean(
123
+ repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.findVersionById && repository.findActivePlanVersion
124
+ );
125
+ }
126
+ },
127
+ planRetirement: {
128
+ reason: "adapter provides no PlanRepository that retires and finds plans by key",
129
+ present: ({ adapter }) => Boolean(adapter.planRepository?.softDelete && adapter.planRepository.findByKey)
130
+ },
131
+ planVersionReads: {
132
+ reason: "adapter provides no PlanRepository that reads versions by plan key",
133
+ present: ({ adapter }) => {
134
+ const repository = adapter.planRepository;
135
+ return Boolean(
136
+ repository?.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion
137
+ );
138
+ }
139
+ },
140
+ planVersionRetirement: {
141
+ reason: "adapter provides no PlanRepository that reads versions and retires plans",
142
+ present: ({ adapter }) => {
143
+ const repository = adapter.planRepository;
144
+ return Boolean(
145
+ repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion && repository.softDelete
146
+ );
147
+ }
148
+ },
149
+ bundleRepository: {
150
+ reason: "adapter provides no BundleRepository",
151
+ present: ({ adapter }) => Boolean(adapter.bundleRepository)
152
+ },
153
+ bundleValidity: {
154
+ reason: "adapter provides no time-aware BundleRepository",
155
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.findActiveBundleVersion)
156
+ },
157
+ bundleDraftDiscard: {
158
+ reason: "adapter provides no BundleRepository that discards drafts",
159
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.deleteDraft)
160
+ },
161
+ bundleDraftPublish: {
162
+ reason: "adapter provides no BundleRepository that publishes drafts",
163
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.publishDraft)
164
+ },
165
+ bundleRetirement: {
166
+ reason: "adapter provides no BundleRepository that retires and finds bundles by key",
167
+ present: ({ adapter }) => Boolean(adapter.bundleRepository?.softDelete && adapter.bundleRepository.findByKey)
168
+ },
169
+ bundleBookings: {
170
+ reason: "adapter provides no SubscriptionBundleRepository or bundle catalog",
171
+ present: ({ adapter, seed }) => Boolean(adapter.subscriptionBundleRepository && seed.createBundleVersion)
172
+ },
173
+ halfCancelledBookingSeed: {
174
+ reason: "adapter harness cannot write the half-cancelled shape",
175
+ present: ({ seed }) => Boolean(seed.clearBookingRequestDate)
176
+ },
177
+ countByPlanVersionId: {
178
+ reason: "adapter does not implement countByPlanVersionId (fail-closed fallback)",
179
+ present: ({ adapter }) => Boolean(adapter.subscriptionRepository.countByPlanVersionId)
180
+ },
181
+ audit: {
182
+ reason: "adapter provides no AuditPort/AuditQueryPort pair",
183
+ present: ({ adapter }) => Boolean(adapter.audit && adapter.auditQuery)
184
+ },
185
+ mfa: {
186
+ reason: "adapter provides no MfaPort",
187
+ present: ({ adapter }) => Boolean(adapter.mfa)
188
+ },
189
+ subscriptionContracts: {
190
+ reason: "adapter provides no SubscriptionContractRepository",
191
+ present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
192
+ },
193
+ checkoutOffers: {
194
+ reason: "adapter provides no CheckoutOfferRepository",
195
+ present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
196
+ },
197
+ appliedSettings: {
198
+ reason: "adapter provides no AppliedSettingsPort",
199
+ present: ({ adapter }) => Boolean(adapter.appliedSettings)
200
+ }
201
+ };
41
202
  function sleep(ms) {
42
203
  return new Promise((resolve) => setTimeout(resolve, ms));
43
204
  }
44
205
  function persistenceAdapterContract(options) {
206
+ const declaredGaps = new Set(options.gaps ?? []);
207
+ let harness;
208
+ function missing(t, gap) {
209
+ const { reason, present } = CONTRACT_GAPS[gap];
210
+ if (present(harness)) {
211
+ import_strict.default.fail(
212
+ `'${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.`
213
+ );
214
+ }
215
+ if (declaredGaps.has(gap)) {
216
+ t.skip(reason);
217
+ return;
218
+ }
219
+ import_strict.default.fail(
220
+ `${reason}. Wire it into the harness, or declare \`gaps: ['${gap}']\` in persistenceAdapterContract if the adapter deliberately does not provide it.`
221
+ );
222
+ }
45
223
  (0, import_node_test.describe)(`persistence adapter contract: ${options.name}`, () => {
46
- let harness;
47
224
  (0, import_node_test.before)(async () => {
48
225
  harness = await options.create();
49
226
  });
@@ -53,6 +230,20 @@ function persistenceAdapterContract(options) {
53
230
  (0, import_node_test.beforeEach)(async () => {
54
231
  await harness.reset();
55
232
  });
233
+ (0, import_node_test.test)("the declared gaps are exactly the parts the harness does not provide", () => {
234
+ const gaps = Object.keys(CONTRACT_GAPS);
235
+ const absent = gaps.filter((gap) => !CONTRACT_GAPS[gap].present(harness));
236
+ const known = (gap) => Object.prototype.hasOwnProperty.call(CONTRACT_GAPS, gap);
237
+ const unknown = [...declaredGaps].filter((gap) => !known(gap));
238
+ const stale = [...declaredGaps].filter((gap) => known(gap) && !absent.includes(gap));
239
+ const undeclared = absent.filter((gap) => !declaredGaps.has(gap));
240
+ const problems = [
241
+ unknown.length > 0 && `declared as gaps but not parts of the contract: ${unknown.join(", ")} (ContractGap lists the names)`,
242
+ stale.length > 0 && `declared as gaps but wired into the harness: ${stale.join(", ")}`,
243
+ undeclared.length > 0 && `not wired into the harness and not declared as gaps: ${undeclared.join(", ")}`
244
+ ].filter(Boolean);
245
+ import_strict.default.deepEqual(problems, [], problems.join("; "));
246
+ });
56
247
  (0, import_node_test.test)("findByTenantId returns the tenant subscription with plan-version limits", async () => {
57
248
  const { seed, adapter } = harness;
58
249
  const { planVersionId } = await seed.createPlanVersion({
@@ -127,7 +318,7 @@ function persistenceAdapterContract(options) {
127
318
  (0, import_node_test.test)("immediate plan change binds plan and active PlanVersion consistently", async (t) => {
128
319
  const { seed, adapter } = harness;
129
320
  if (!adapter.tenantSubscriptionWrite) {
130
- t.skip("adapter does not expose atomic plan-binding writes");
321
+ missing(t, "atomicPlanBinding");
131
322
  return;
132
323
  }
133
324
  const oldVersion = await seed.createPlanVersion({
@@ -172,12 +363,12 @@ function persistenceAdapterContract(options) {
172
363
  const { seed, adapter } = harness;
173
364
  const writer = adapter.tenantSubscriptionWrite;
174
365
  if (!writer?.applyOnboardingSelection) {
175
- t.skip("adapter does not expose atomic onboarding writes");
366
+ missing(t, "atomicOnboarding");
176
367
  return;
177
368
  }
178
369
  const redemptions = adapter.promoCodeRedemptionRepository;
179
370
  if (!redemptions) {
180
- t.skip("adapter provides no PromoCodeRedemptionRepository");
371
+ missing(t, "promoCodeRedemptions");
181
372
  return;
182
373
  }
183
374
  const oldVersion = await seed.createPlanVersion({
@@ -256,7 +447,7 @@ function persistenceAdapterContract(options) {
256
447
  (0, import_node_test.test)("plan lifecycle keeps semantic identity and auto-succeeds validity windows", async (t) => {
257
448
  const repository = harness.adapter.planRepository;
258
449
  if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.findVersionById || !repository.findActivePlanVersion) {
259
- t.skip("adapter provides no time-aware PlanRepository lifecycle");
450
+ missing(t, "planLifecycle");
260
451
  return;
261
452
  }
262
453
  const plan = await repository.create({
@@ -320,7 +511,7 @@ function persistenceAdapterContract(options) {
320
511
  (0, import_node_test.test)("bundle lifecycle roundtrips validity and auto-succeeds atomically", async (t) => {
321
512
  const repository = harness.adapter.bundleRepository;
322
513
  if (!repository?.findActiveBundleVersion) {
323
- t.skip("adapter provides no time-aware BundleRepository");
514
+ missing(t, "bundleValidity");
324
515
  return;
325
516
  }
326
517
  const bundle = await repository.create({
@@ -379,7 +570,7 @@ function persistenceAdapterContract(options) {
379
570
  const repository = harness.adapter.subscriptionBundleRepository;
380
571
  const { seed } = harness;
381
572
  if (!repository || !seed.createBundleVersion) {
382
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
573
+ missing(t, "bundleBookings");
383
574
  return;
384
575
  }
385
576
  const { planVersionId } = await seed.createPlanVersion({
@@ -434,7 +625,7 @@ function persistenceAdapterContract(options) {
434
625
  const repository = harness.adapter.subscriptionBundleRepository;
435
626
  const { seed } = harness;
436
627
  if (!repository || !seed.createBundleVersion) {
437
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
628
+ missing(t, "bundleBookings");
438
629
  return;
439
630
  }
440
631
  const { planVersionId } = await seed.createPlanVersion({
@@ -488,7 +679,7 @@ function persistenceAdapterContract(options) {
488
679
  const repository = harness.adapter.subscriptionBundleRepository;
489
680
  const { seed } = harness;
490
681
  if (!repository || !seed.createBundleVersion) {
491
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
682
+ missing(t, "bundleBookings");
492
683
  return;
493
684
  }
494
685
  const { planVersionId } = await seed.createPlanVersion({
@@ -533,7 +724,7 @@ function persistenceAdapterContract(options) {
533
724
  const repository = harness.adapter.subscriptionBundleRepository;
534
725
  const { seed } = harness;
535
726
  if (!repository || !seed.createBundleVersion) {
536
- t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
727
+ missing(t, "bundleBookings");
537
728
  return;
538
729
  }
539
730
  const { planVersionId } = await seed.createPlanVersion({
@@ -564,7 +755,7 @@ function persistenceAdapterContract(options) {
564
755
  });
565
756
  const clearRequestDate = harness.seed.clearBookingRequestDate;
566
757
  if (!clearRequestDate) {
567
- t.skip("adapter harness cannot write the half-cancelled shape");
758
+ missing(t, "halfCancelledBookingSeed");
568
759
  return;
569
760
  }
570
761
  await clearRequestDate(booking.id);
@@ -585,7 +776,7 @@ function persistenceAdapterContract(options) {
585
776
  const catalog = harness.adapter.bundleRepository;
586
777
  const discardDraft = catalog?.deleteDraft?.bind(catalog);
587
778
  if (!catalog || !discardDraft) {
588
- t.skip("adapter provides no BundleRepository");
779
+ missing(t, "bundleDraftDiscard");
589
780
  return;
590
781
  }
591
782
  const bundle = await catalog.create({
@@ -617,7 +808,7 @@ function persistenceAdapterContract(options) {
617
808
  const catalog = harness.adapter.bundleRepository;
618
809
  const publish = catalog?.publishDraft?.bind(catalog);
619
810
  if (!catalog || !publish) {
620
- t.skip("adapter provides no BundleRepository");
811
+ missing(t, "bundleDraftPublish");
621
812
  return;
622
813
  }
623
814
  const bundle = await catalog.create({
@@ -680,7 +871,7 @@ function persistenceAdapterContract(options) {
680
871
  (0, import_node_test.test)("a plan key names one plan for the whole installation", async (t) => {
681
872
  const repository = harness.adapter.planRepository;
682
873
  if (!repository) {
683
- t.skip("adapter provides no PlanRepository");
874
+ missing(t, "planRepository");
684
875
  return;
685
876
  }
686
877
  await repository.create({ planKey: "DOUBLE", label: "First" });
@@ -692,7 +883,7 @@ function persistenceAdapterContract(options) {
692
883
  (0, import_node_test.test)("a bundle key names one bundle for the whole installation", async (t) => {
693
884
  const catalog = harness.adapter.bundleRepository;
694
885
  if (!catalog) {
695
- t.skip("adapter provides no BundleRepository");
886
+ missing(t, "bundleRepository");
696
887
  return;
697
888
  }
698
889
  await catalog.create({ bundleKey: "DOUBLE", label: "First" });
@@ -706,7 +897,7 @@ function persistenceAdapterContract(options) {
706
897
  const retire = repository?.softDelete?.bind(repository);
707
898
  const byKey = repository?.findByKey?.bind(repository);
708
899
  if (!repository || !retire || !byKey) {
709
- t.skip("adapter provides no PlanRepository");
900
+ missing(t, "planRetirement");
710
901
  return;
711
902
  }
712
903
  const plan = await repository.create({ planKey: "RETIRED_PLAN", label: "Retired" });
@@ -720,12 +911,90 @@ function persistenceAdapterContract(options) {
720
911
  "a retired plan is not in the catalogue an operator browses"
721
912
  );
722
913
  });
914
+ (0, import_node_test.test)("a plan key no plan has finds no versions, rather than failing", async (t) => {
915
+ const repository = harness.adapter.planRepository;
916
+ if (!repository?.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion) {
917
+ missing(t, "planVersionReads");
918
+ return;
919
+ }
920
+ const entitlementVersions = harness.adapter.planVersionRepository;
921
+ import_strict.default.deepEqual(await repository.listVersions("NO_SUCH_PLAN"), []);
922
+ import_strict.default.equal(await repository.findCurrentDraft("NO_SUCH_PLAN"), null);
923
+ import_strict.default.equal(await repository.findLatestLivePlanVersion("NO_SUCH_PLAN"), null);
924
+ if (repository.findActivePlanVersion) {
925
+ import_strict.default.equal(
926
+ await repository.findActivePlanVersion("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
927
+ null
928
+ );
929
+ }
930
+ import_strict.default.equal(await entitlementVersions.findLatestLive("NO_SUCH_PLAN"), null);
931
+ if (entitlementVersions.findActive) {
932
+ import_strict.default.equal(
933
+ await entitlementVersions.findActive("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
934
+ null
935
+ );
936
+ }
937
+ });
938
+ (0, import_node_test.test)("retiring a plan hides none of its versions", async (t) => {
939
+ const repository = harness.adapter.planRepository;
940
+ if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion || !repository.softDelete) {
941
+ missing(t, "planVersionRetirement");
942
+ return;
943
+ }
944
+ const listVersions = repository.listVersions.bind(repository);
945
+ const findCurrentDraft = repository.findCurrentDraft.bind(repository);
946
+ const findLatestLive = repository.findLatestLivePlanVersion.bind(repository);
947
+ const entitlementVersions = harness.adapter.planVersionRepository;
948
+ const plan = await repository.create({ planKey: "RETIRING", label: "Retiring" });
949
+ const firstDraft = await repository.createPlanVersionDraft({
950
+ planId: "RETIRING",
951
+ features: ["CORE"],
952
+ quotas: { users: 5 },
953
+ monthlyNet: "10.00",
954
+ yearlyNet: "100.00",
955
+ validFrom: "2026-01-01"
956
+ });
957
+ const live = await repository.publishPlanVersionDraft(firstDraft.id, {
958
+ publishedByUserId: null,
959
+ publishedChanges: [],
960
+ nonRegressive: true,
961
+ validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
962
+ validUntil: null
963
+ });
964
+ const openDraft = await repository.createPlanVersionDraft({
965
+ planId: "RETIRING",
966
+ baseVersionId: live.id,
967
+ features: ["CORE", "PLUS"],
968
+ quotas: { users: 10 },
969
+ monthlyNet: "15.00",
970
+ yearlyNet: "150.00",
971
+ validFrom: "2026-06-01"
972
+ });
973
+ const reads = async () => ({
974
+ versions: (await listVersions("RETIRING")).map((row) => row.id),
975
+ draft: (await findCurrentDraft("RETIRING"))?.id ?? null,
976
+ latestLive: (await findLatestLive("RETIRING"))?.id ?? null,
977
+ entitlementFeatures: (await entitlementVersions.findLatestLive("RETIRING"))?.features ?? null
978
+ });
979
+ const beforeRetiring = await reads();
980
+ import_strict.default.deepEqual(
981
+ {
982
+ versions: beforeRetiring.versions,
983
+ draft: beforeRetiring.draft,
984
+ latestLive: beforeRetiring.latestLive
985
+ },
986
+ { versions: [live.id, openDraft.id], draft: openDraft.id, latestLive: live.id },
987
+ "a live plan reads its versions, so the comparison below has a subject"
988
+ );
989
+ await repository.softDelete(plan.id);
990
+ import_strict.default.deepEqual(await reads(), beforeRetiring, "retiring the plan hid its versions");
991
+ });
723
992
  (0, import_node_test.test)("a retired bundle still occupies its key", async (t) => {
724
993
  const catalog = harness.adapter.bundleRepository;
725
994
  const retire = catalog?.softDelete?.bind(catalog);
726
995
  const byKey = catalog?.findByKey?.bind(catalog);
727
996
  if (!catalog || !retire || !byKey) {
728
- t.skip("adapter provides no BundleRepository");
997
+ missing(t, "bundleRetirement");
729
998
  return;
730
999
  }
731
1000
  const bundle = await catalog.create({
@@ -747,7 +1016,7 @@ function persistenceAdapterContract(options) {
747
1016
  (0, import_node_test.test)("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
748
1017
  const { seed, adapter } = harness;
749
1018
  if (!adapter.subscriptionRepository.countByPlanVersionId) {
750
- t.skip("adapter does not implement countByPlanVersionId (fail-closed fallback)");
1019
+ missing(t, "countByPlanVersionId");
751
1020
  return;
752
1021
  }
753
1022
  const v1 = await seed.createPlanVersion({
@@ -787,9 +1056,7 @@ function persistenceAdapterContract(options) {
787
1056
  return;
788
1057
  }
789
1058
  if (!adapter.promoCodeRedemptionRepository) {
790
- t.skip(
791
- "adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)"
792
- );
1059
+ missing(t, "promoCodeRedemptions");
793
1060
  return;
794
1061
  }
795
1062
  const redemptions = adapter.promoCodeRedemptionRepository;
@@ -872,7 +1139,7 @@ function persistenceAdapterContract(options) {
872
1139
  (0, import_node_test.test)("concurrent claimSlot grants exactly maxRedemptions slots", async (t) => {
873
1140
  const { seed, adapter } = harness;
874
1141
  if (!adapter.promoCodeRepository) {
875
- t.skip("adapter provides no PromoCodeRepository");
1142
+ missing(t, "promoCodes");
876
1143
  return;
877
1144
  }
878
1145
  const promoCodes = adapter.promoCodeRepository;
@@ -893,7 +1160,7 @@ function persistenceAdapterContract(options) {
893
1160
  (0, import_node_test.test)("claimSlot / markExhaustedIfFull / releaseSlot lifecycle", async (t) => {
894
1161
  const { seed, adapter } = harness;
895
1162
  if (!adapter.promoCodeRepository) {
896
- t.skip("adapter provides no PromoCodeRepository");
1163
+ missing(t, "promoCodes");
897
1164
  return;
898
1165
  }
899
1166
  const promoCodes = adapter.promoCodeRepository;
@@ -913,7 +1180,7 @@ function persistenceAdapterContract(options) {
913
1180
  (0, import_node_test.test)("a subscription cannot redeem twice (unique guard)", async (t) => {
914
1181
  const { seed, adapter } = harness;
915
1182
  if (!adapter.promoCodeRedemptionRepository) {
916
- t.skip("adapter provides no PromoCodeRedemptionRepository");
1183
+ missing(t, "promoCodeRedemptions");
917
1184
  return;
918
1185
  }
919
1186
  const redemptions = adapter.promoCodeRedemptionRepository;
@@ -953,7 +1220,7 @@ function persistenceAdapterContract(options) {
953
1220
  (0, import_node_test.test)("audit write \u2192 query roundtrip with actorTag filters", async (t) => {
954
1221
  const { adapter } = harness;
955
1222
  if (!adapter.audit || !adapter.auditQuery) {
956
- t.skip("adapter provides no AuditPort/AuditQueryPort pair");
1223
+ missing(t, "audit");
957
1224
  return;
958
1225
  }
959
1226
  await adapter.audit.write({
@@ -993,7 +1260,7 @@ function persistenceAdapterContract(options) {
993
1260
  (0, import_node_test.test)("MFA secret roundtrip", async (t) => {
994
1261
  const { adapter } = harness;
995
1262
  if (!adapter.mfa) {
996
- t.skip("adapter provides no MfaPort");
1263
+ missing(t, "mfa");
997
1264
  return;
998
1265
  }
999
1266
  import_strict.default.equal(await adapter.mfa.getSecret("admin-1"), null);
@@ -1008,7 +1275,7 @@ function persistenceAdapterContract(options) {
1008
1275
  (0, import_node_test.test)("finds the subscription the id names, not merely a subscription", async (t) => {
1009
1276
  const { adapter, seed } = harness;
1010
1277
  if (!adapter.promoSubscriptionLookup) {
1011
- t.skip("adapter provides no PromoSubscriptionLookup");
1278
+ missing(t, "promoSubscriptionLookup");
1012
1279
  return;
1013
1280
  }
1014
1281
  const { planVersionId } = await seed.createPlanVersion({
@@ -1037,7 +1304,7 @@ function persistenceAdapterContract(options) {
1037
1304
  (0, import_node_test.test)("returns null for an id that does not exist", async (t) => {
1038
1305
  const { adapter, seed } = harness;
1039
1306
  if (!adapter.promoSubscriptionLookup) {
1040
- t.skip("adapter provides no PromoSubscriptionLookup");
1307
+ missing(t, "promoSubscriptionLookup");
1041
1308
  return;
1042
1309
  }
1043
1310
  const { planVersionId } = await seed.createPlanVersion({
@@ -1058,7 +1325,7 @@ function persistenceAdapterContract(options) {
1058
1325
  (0, import_node_test.test)("carries the fields a promo rule reads: cycle and start date", async (t) => {
1059
1326
  const { adapter, seed } = harness;
1060
1327
  if (!adapter.promoSubscriptionLookup) {
1061
- t.skip("adapter provides no PromoSubscriptionLookup");
1328
+ missing(t, "promoSubscriptionLookup");
1062
1329
  return;
1063
1330
  }
1064
1331
  const { planVersionId } = await seed.createPlanVersion({
@@ -1093,7 +1360,7 @@ function persistenceAdapterContract(options) {
1093
1360
  (0, import_node_test.test)("reads inside a transaction, so validation and redemption agree", async (t) => {
1094
1361
  const { adapter, seed } = harness;
1095
1362
  if (!adapter.promoSubscriptionLookup) {
1096
- t.skip("adapter provides no PromoSubscriptionLookup");
1363
+ missing(t, "promoSubscriptionLookup");
1097
1364
  return;
1098
1365
  }
1099
1366
  const { planVersionId } = await seed.createPlanVersion({
@@ -1117,7 +1384,7 @@ function persistenceAdapterContract(options) {
1117
1384
  (0, import_node_test.test)("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
1118
1385
  const contracts = harness.adapter.subscriptionContractRepository;
1119
1386
  if (!contracts) {
1120
- t.skip("adapter provides no SubscriptionContractRepository");
1387
+ missing(t, "subscriptionContracts");
1121
1388
  return;
1122
1389
  }
1123
1390
  const tenantId = "tenant-contract-lifecycle";
@@ -1273,7 +1540,7 @@ function persistenceAdapterContract(options) {
1273
1540
  (0, import_node_test.test)("a successor takes over without erasing the contract it replaces", async (t) => {
1274
1541
  const contracts = harness.adapter.subscriptionContractRepository;
1275
1542
  if (!contracts) {
1276
- t.skip("adapter provides no SubscriptionContractRepository");
1543
+ missing(t, "subscriptionContracts");
1277
1544
  return;
1278
1545
  }
1279
1546
  const tenantId = "tenant-contract-succession";
@@ -1375,7 +1642,7 @@ function persistenceAdapterContract(options) {
1375
1642
  (0, import_node_test.test)("a line keeps the currency and the tax it was booked with", async (t) => {
1376
1643
  const contracts = harness.adapter.subscriptionContractRepository;
1377
1644
  if (!contracts) {
1378
- t.skip("adapter provides no SubscriptionContractRepository");
1645
+ missing(t, "subscriptionContracts");
1379
1646
  return;
1380
1647
  }
1381
1648
  const tenantId = "tenant-contract-money-facts";
@@ -1454,6 +1721,86 @@ function persistenceAdapterContract(options) {
1454
1721
  );
1455
1722
  }
1456
1723
  });
1724
+ (0, import_node_test.test)("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
1725
+ const { adapter } = harness;
1726
+ const contracts = adapter.subscriptionContractRepository;
1727
+ if (!contracts) {
1728
+ missing(t, "subscriptionContracts");
1729
+ return;
1730
+ }
1731
+ if (!adapter.capabilities.transactions) {
1732
+ t.skip("adapter declares no transaction capability");
1733
+ return;
1734
+ }
1735
+ await import_strict.default.rejects(
1736
+ adapter.transactionRunner.run(async (tx) => {
1737
+ await contracts.create(contractFromOffer("offer-rolled-back"), tx);
1738
+ throw new Error("the conclusion fails after the contract is written");
1739
+ })
1740
+ );
1741
+ import_strict.default.equal(
1742
+ await contracts.findByOriginalOfferId("offer-rolled-back"),
1743
+ null,
1744
+ "the contract outlived its transaction"
1745
+ );
1746
+ const kept = await adapter.transactionRunner.run(
1747
+ (tx) => contracts.create(contractFromOffer("offer-kept"), tx)
1748
+ );
1749
+ const found = await contracts.findByOriginalOfferId("offer-kept");
1750
+ import_strict.default.equal(found?.id, kept.id);
1751
+ import_strict.default.equal(found?.lineItems.length, 1, "with its lines");
1752
+ import_strict.default.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
1753
+ });
1754
+ (0, import_node_test.test)("an offer is consumed once, whoever asks first", async (t) => {
1755
+ const offers = harness.adapter.checkoutOfferRepository;
1756
+ if (!offers) {
1757
+ missing(t, "checkoutOffers");
1758
+ return;
1759
+ }
1760
+ const offer = await offers.create(OFFER);
1761
+ import_strict.default.equal(offer.status, "open");
1762
+ const attempts = await Promise.allSettled([
1763
+ offers.consume(offer.id),
1764
+ offers.consume(offer.id)
1765
+ ]);
1766
+ import_strict.default.equal(
1767
+ attempts.filter((attempt) => attempt.status === "fulfilled").length,
1768
+ 1,
1769
+ "exactly one consume wins"
1770
+ );
1771
+ const consumed = await offers.findById(offer.id);
1772
+ import_strict.default.equal(consumed?.status, "consumed");
1773
+ import_strict.default.ok(consumed?.consumedAt, "and it records when");
1774
+ await import_strict.default.rejects(() => offers.consume(offer.id), "a later consume is refused");
1775
+ import_strict.default.equal(
1776
+ (await offers.findById(offer.id))?.consumedAt,
1777
+ consumed.consumedAt,
1778
+ "and changes nothing"
1779
+ );
1780
+ });
1781
+ (0, import_node_test.test)("a consume on a transaction that rolls back leaves the offer open", async (t) => {
1782
+ const { adapter } = harness;
1783
+ const offers = adapter.checkoutOfferRepository;
1784
+ if (!offers) {
1785
+ missing(t, "checkoutOffers");
1786
+ return;
1787
+ }
1788
+ if (!adapter.capabilities.transactions) {
1789
+ t.skip("adapter declares no transaction capability");
1790
+ return;
1791
+ }
1792
+ const offer = await offers.create(OFFER);
1793
+ await import_strict.default.rejects(
1794
+ adapter.transactionRunner.run(async (tx) => {
1795
+ await offers.consume(offer.id, tx);
1796
+ throw new Error("the contract after the consume fails");
1797
+ })
1798
+ );
1799
+ const afterwards = await offers.findById(offer.id);
1800
+ import_strict.default.equal(afterwards?.status, "open", "the consume outlived its transaction");
1801
+ import_strict.default.equal(afterwards?.consumedAt, null);
1802
+ import_strict.default.equal((await offers.consume(offer.id)).status, "consumed");
1803
+ });
1457
1804
  const SETTINGS = {
1458
1805
  app: { name: "Demo" },
1459
1806
  currency: "EUR",
@@ -1473,7 +1820,7 @@ function persistenceAdapterContract(options) {
1473
1820
  (0, import_node_test.test)("no record before the first boot that could write one", async (t) => {
1474
1821
  const port = harness.adapter.appliedSettings;
1475
1822
  if (!port) {
1476
- t.skip("adapter provides no AppliedSettingsPort");
1823
+ missing(t, "appliedSettings");
1477
1824
  return;
1478
1825
  }
1479
1826
  import_strict.default.equal(await port.readApplied(), null);
@@ -1482,7 +1829,7 @@ function persistenceAdapterContract(options) {
1482
1829
  (0, import_node_test.test)("the record comes back as it was written \u2014 values, source and moment", async (t) => {
1483
1830
  const port = harness.adapter.appliedSettings;
1484
1831
  if (!port) {
1485
- t.skip("adapter provides no AppliedSettingsPort");
1832
+ missing(t, "appliedSettings");
1486
1833
  return;
1487
1834
  }
1488
1835
  import_strict.default.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
@@ -1496,7 +1843,7 @@ function persistenceAdapterContract(options) {
1496
1843
  (0, import_node_test.test)("writing again replaces the one row rather than adding a second", async (t) => {
1497
1844
  const port = harness.adapter.appliedSettings;
1498
1845
  if (!port) {
1499
- t.skip("adapter provides no AppliedSettingsPort");
1846
+ missing(t, "appliedSettings");
1500
1847
  return;
1501
1848
  }
1502
1849
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1512,7 +1859,7 @@ function persistenceAdapterContract(options) {
1512
1859
  (0, import_node_test.test)("the first record is written once: a second writer that read none is refused", async (t) => {
1513
1860
  const port = harness.adapter.appliedSettings;
1514
1861
  if (!port) {
1515
- t.skip("adapter provides no AppliedSettingsPort");
1862
+ missing(t, "appliedSettings");
1516
1863
  return;
1517
1864
  }
1518
1865
  import_strict.default.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
@@ -1522,7 +1869,7 @@ function persistenceAdapterContract(options) {
1522
1869
  (0, import_node_test.test)("a write guarded on a fingerprint the row no longer carries is refused", async (t) => {
1523
1870
  const port = harness.adapter.appliedSettings;
1524
1871
  if (!port) {
1525
- t.skip("adapter provides no AppliedSettingsPort");
1872
+ missing(t, "appliedSettings");
1526
1873
  return;
1527
1874
  }
1528
1875
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1545,7 +1892,7 @@ function persistenceAdapterContract(options) {
1545
1892
  (0, import_node_test.test)("a change lands with the record it supersedes, and is listed newest first", async (t) => {
1546
1893
  const port = harness.adapter.appliedSettings;
1547
1894
  if (!port) {
1548
- t.skip("adapter provides no AppliedSettingsPort");
1895
+ missing(t, "appliedSettings");
1549
1896
  return;
1550
1897
  }
1551
1898
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1583,7 +1930,7 @@ function persistenceAdapterContract(options) {
1583
1930
  (0, import_node_test.test)("a change whose record has moved on is refused whole: no change, and the record as it was", async (t) => {
1584
1931
  const port = harness.adapter.appliedSettings;
1585
1932
  if (!port) {
1586
- t.skip("adapter provides no AppliedSettingsPort");
1933
+ missing(t, "appliedSettings");
1587
1934
  return;
1588
1935
  }
1589
1936
  await port.writeApplied(applied("sha256-a", FIRST_START), null);
@@ -1601,7 +1948,7 @@ function persistenceAdapterContract(options) {
1601
1948
  (0, import_node_test.test)("starts noticing the same difference at once record it once", 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-a", FIRST_START), null);
@@ -1625,7 +1972,7 @@ function persistenceAdapterContract(options) {
1625
1972
  (0, import_node_test.test)("several first starts write the record once", async (t) => {
1626
1973
  const port = harness.adapter.appliedSettings;
1627
1974
  if (!port) {
1628
- t.skip("adapter provides no AppliedSettingsPort");
1975
+ missing(t, "appliedSettings");
1629
1976
  return;
1630
1977
  }
1631
1978
  const written = await Promise.all(
@@ -1637,7 +1984,7 @@ function persistenceAdapterContract(options) {
1637
1984
  (0, import_node_test.test)("changes are listed in the order they were recorded, latest first \u2014 not by the moment they carry", async (t) => {
1638
1985
  const port = harness.adapter.appliedSettings;
1639
1986
  if (!port) {
1640
- t.skip("adapter provides no AppliedSettingsPort");
1987
+ missing(t, "appliedSettings");
1641
1988
  return;
1642
1989
  }
1643
1990
  await port.writeApplied(applied("sha256-0", FIRST_START, {}), null);
@@ -1669,7 +2016,7 @@ function persistenceAdapterContract(options) {
1669
2016
  (0, import_node_test.test)("acknowledging a change is recorded once, and filters it out of what is owed", async (t) => {
1670
2017
  const port = harness.adapter.appliedSettings;
1671
2018
  if (!port) {
1672
- t.skip("adapter provides no AppliedSettingsPort");
2019
+ missing(t, "appliedSettings");
1673
2020
  return;
1674
2021
  }
1675
2022
  await port.writeApplied(applied("sha256-a", FIRST_START), null);