@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.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
|
|
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
|
|
366
|
+
missing(t, "atomicOnboarding");
|
|
176
367
|
return;
|
|
177
368
|
}
|
|
178
369
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
179
370
|
if (!redemptions) {
|
|
180
|
-
t
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2019
|
+
missing(t, "appliedSettings");
|
|
1673
2020
|
return;
|
|
1674
2021
|
}
|
|
1675
2022
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|