@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/README.md +36 -10
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +391 -44
- package/dist/index.d.cts +26 -4
- package/dist/index.d.ts +26 -4
- package/dist/index.js +391 -44
- package/package.json +2 -2
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
|
|
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
|
|
330
|
+
missing(t, "atomicOnboarding");
|
|
140
331
|
return;
|
|
141
332
|
}
|
|
142
333
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
143
334
|
if (!redemptions) {
|
|
144
|
-
t
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1983
|
+
missing(t, "appliedSettings");
|
|
1637
1984
|
return;
|
|
1638
1985
|
}
|
|
1639
1986
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|