@saasicat/persistence-testing 1.0.0-rc.13 → 1.0.0-rc.14
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 +33 -10
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +255 -44
- package/dist/index.d.cts +18 -3
- package/dist/index.d.ts +18 -3
- package/dist/index.js +255 -44
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2,12 +2,133 @@
|
|
|
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 CONTRACT_GAPS = {
|
|
6
|
+
atomicPlanBinding: {
|
|
7
|
+
reason: "adapter does not expose atomic plan-binding writes",
|
|
8
|
+
present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite)
|
|
9
|
+
},
|
|
10
|
+
atomicOnboarding: {
|
|
11
|
+
reason: "adapter does not expose atomic onboarding writes",
|
|
12
|
+
present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite?.applyOnboardingSelection)
|
|
13
|
+
},
|
|
14
|
+
promoCodes: {
|
|
15
|
+
reason: "adapter provides no PromoCodeRepository",
|
|
16
|
+
present: ({ adapter }) => Boolean(adapter.promoCodeRepository)
|
|
17
|
+
},
|
|
18
|
+
promoCodeRedemptions: {
|
|
19
|
+
reason: "adapter provides no PromoCodeRedemptionRepository",
|
|
20
|
+
present: ({ adapter }) => Boolean(adapter.promoCodeRedemptionRepository)
|
|
21
|
+
},
|
|
22
|
+
promoSubscriptionLookup: {
|
|
23
|
+
reason: "adapter provides no PromoSubscriptionLookup",
|
|
24
|
+
present: ({ adapter }) => Boolean(adapter.promoSubscriptionLookup)
|
|
25
|
+
},
|
|
26
|
+
planRepository: {
|
|
27
|
+
reason: "adapter provides no PlanRepository",
|
|
28
|
+
present: ({ adapter }) => Boolean(adapter.planRepository)
|
|
29
|
+
},
|
|
30
|
+
planLifecycle: {
|
|
31
|
+
reason: "adapter provides no time-aware PlanRepository lifecycle",
|
|
32
|
+
present: ({ adapter }) => {
|
|
33
|
+
const repository = adapter.planRepository;
|
|
34
|
+
return Boolean(
|
|
35
|
+
repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.findVersionById && repository.findActivePlanVersion
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
planRetirement: {
|
|
40
|
+
reason: "adapter provides no PlanRepository that retires and finds plans by key",
|
|
41
|
+
present: ({ adapter }) => Boolean(adapter.planRepository?.softDelete && adapter.planRepository.findByKey)
|
|
42
|
+
},
|
|
43
|
+
planVersionReads: {
|
|
44
|
+
reason: "adapter provides no PlanRepository that reads versions by plan key",
|
|
45
|
+
present: ({ adapter }) => {
|
|
46
|
+
const repository = adapter.planRepository;
|
|
47
|
+
return Boolean(
|
|
48
|
+
repository?.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
planVersionRetirement: {
|
|
53
|
+
reason: "adapter provides no PlanRepository that reads versions and retires plans",
|
|
54
|
+
present: ({ adapter }) => {
|
|
55
|
+
const repository = adapter.planRepository;
|
|
56
|
+
return Boolean(
|
|
57
|
+
repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion && repository.softDelete
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
bundleRepository: {
|
|
62
|
+
reason: "adapter provides no BundleRepository",
|
|
63
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository)
|
|
64
|
+
},
|
|
65
|
+
bundleValidity: {
|
|
66
|
+
reason: "adapter provides no time-aware BundleRepository",
|
|
67
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.findActiveBundleVersion)
|
|
68
|
+
},
|
|
69
|
+
bundleDraftDiscard: {
|
|
70
|
+
reason: "adapter provides no BundleRepository that discards drafts",
|
|
71
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.deleteDraft)
|
|
72
|
+
},
|
|
73
|
+
bundleDraftPublish: {
|
|
74
|
+
reason: "adapter provides no BundleRepository that publishes drafts",
|
|
75
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.publishDraft)
|
|
76
|
+
},
|
|
77
|
+
bundleRetirement: {
|
|
78
|
+
reason: "adapter provides no BundleRepository that retires and finds bundles by key",
|
|
79
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.softDelete && adapter.bundleRepository.findByKey)
|
|
80
|
+
},
|
|
81
|
+
bundleBookings: {
|
|
82
|
+
reason: "adapter provides no SubscriptionBundleRepository or bundle catalog",
|
|
83
|
+
present: ({ adapter, seed }) => Boolean(adapter.subscriptionBundleRepository && seed.createBundleVersion)
|
|
84
|
+
},
|
|
85
|
+
halfCancelledBookingSeed: {
|
|
86
|
+
reason: "adapter harness cannot write the half-cancelled shape",
|
|
87
|
+
present: ({ seed }) => Boolean(seed.clearBookingRequestDate)
|
|
88
|
+
},
|
|
89
|
+
countByPlanVersionId: {
|
|
90
|
+
reason: "adapter does not implement countByPlanVersionId (fail-closed fallback)",
|
|
91
|
+
present: ({ adapter }) => Boolean(adapter.subscriptionRepository.countByPlanVersionId)
|
|
92
|
+
},
|
|
93
|
+
audit: {
|
|
94
|
+
reason: "adapter provides no AuditPort/AuditQueryPort pair",
|
|
95
|
+
present: ({ adapter }) => Boolean(adapter.audit && adapter.auditQuery)
|
|
96
|
+
},
|
|
97
|
+
mfa: {
|
|
98
|
+
reason: "adapter provides no MfaPort",
|
|
99
|
+
present: ({ adapter }) => Boolean(adapter.mfa)
|
|
100
|
+
},
|
|
101
|
+
subscriptionContracts: {
|
|
102
|
+
reason: "adapter provides no SubscriptionContractRepository",
|
|
103
|
+
present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
|
|
104
|
+
},
|
|
105
|
+
appliedSettings: {
|
|
106
|
+
reason: "adapter provides no AppliedSettingsPort",
|
|
107
|
+
present: ({ adapter }) => Boolean(adapter.appliedSettings)
|
|
108
|
+
}
|
|
109
|
+
};
|
|
5
110
|
function sleep(ms) {
|
|
6
111
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
112
|
}
|
|
8
113
|
function persistenceAdapterContract(options) {
|
|
114
|
+
const declaredGaps = new Set(options.gaps ?? []);
|
|
115
|
+
let harness;
|
|
116
|
+
function missing(t, gap) {
|
|
117
|
+
const { reason, present } = CONTRACT_GAPS[gap];
|
|
118
|
+
if (present(harness)) {
|
|
119
|
+
assert.fail(
|
|
120
|
+
`'${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.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (declaredGaps.has(gap)) {
|
|
124
|
+
t.skip(reason);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
assert.fail(
|
|
128
|
+
`${reason}. Wire it into the harness, or declare \`gaps: ['${gap}']\` in persistenceAdapterContract if the adapter deliberately does not provide it.`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
9
131
|
describe(`persistence adapter contract: ${options.name}`, () => {
|
|
10
|
-
let harness;
|
|
11
132
|
before(async () => {
|
|
12
133
|
harness = await options.create();
|
|
13
134
|
});
|
|
@@ -17,6 +138,20 @@ function persistenceAdapterContract(options) {
|
|
|
17
138
|
beforeEach(async () => {
|
|
18
139
|
await harness.reset();
|
|
19
140
|
});
|
|
141
|
+
test("the declared gaps are exactly the parts the harness does not provide", () => {
|
|
142
|
+
const gaps = Object.keys(CONTRACT_GAPS);
|
|
143
|
+
const absent = gaps.filter((gap) => !CONTRACT_GAPS[gap].present(harness));
|
|
144
|
+
const known = (gap) => Object.prototype.hasOwnProperty.call(CONTRACT_GAPS, gap);
|
|
145
|
+
const unknown = [...declaredGaps].filter((gap) => !known(gap));
|
|
146
|
+
const stale = [...declaredGaps].filter((gap) => known(gap) && !absent.includes(gap));
|
|
147
|
+
const undeclared = absent.filter((gap) => !declaredGaps.has(gap));
|
|
148
|
+
const problems = [
|
|
149
|
+
unknown.length > 0 && `declared as gaps but not parts of the contract: ${unknown.join(", ")} (ContractGap lists the names)`,
|
|
150
|
+
stale.length > 0 && `declared as gaps but wired into the harness: ${stale.join(", ")}`,
|
|
151
|
+
undeclared.length > 0 && `not wired into the harness and not declared as gaps: ${undeclared.join(", ")}`
|
|
152
|
+
].filter(Boolean);
|
|
153
|
+
assert.deepEqual(problems, [], problems.join("; "));
|
|
154
|
+
});
|
|
20
155
|
test("findByTenantId returns the tenant subscription with plan-version limits", async () => {
|
|
21
156
|
const { seed, adapter } = harness;
|
|
22
157
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -91,7 +226,7 @@ function persistenceAdapterContract(options) {
|
|
|
91
226
|
test("immediate plan change binds plan and active PlanVersion consistently", async (t) => {
|
|
92
227
|
const { seed, adapter } = harness;
|
|
93
228
|
if (!adapter.tenantSubscriptionWrite) {
|
|
94
|
-
t
|
|
229
|
+
missing(t, "atomicPlanBinding");
|
|
95
230
|
return;
|
|
96
231
|
}
|
|
97
232
|
const oldVersion = await seed.createPlanVersion({
|
|
@@ -136,12 +271,12 @@ function persistenceAdapterContract(options) {
|
|
|
136
271
|
const { seed, adapter } = harness;
|
|
137
272
|
const writer = adapter.tenantSubscriptionWrite;
|
|
138
273
|
if (!writer?.applyOnboardingSelection) {
|
|
139
|
-
t
|
|
274
|
+
missing(t, "atomicOnboarding");
|
|
140
275
|
return;
|
|
141
276
|
}
|
|
142
277
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
143
278
|
if (!redemptions) {
|
|
144
|
-
t
|
|
279
|
+
missing(t, "promoCodeRedemptions");
|
|
145
280
|
return;
|
|
146
281
|
}
|
|
147
282
|
const oldVersion = await seed.createPlanVersion({
|
|
@@ -220,7 +355,7 @@ function persistenceAdapterContract(options) {
|
|
|
220
355
|
test("plan lifecycle keeps semantic identity and auto-succeeds validity windows", async (t) => {
|
|
221
356
|
const repository = harness.adapter.planRepository;
|
|
222
357
|
if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.findVersionById || !repository.findActivePlanVersion) {
|
|
223
|
-
t
|
|
358
|
+
missing(t, "planLifecycle");
|
|
224
359
|
return;
|
|
225
360
|
}
|
|
226
361
|
const plan = await repository.create({
|
|
@@ -284,7 +419,7 @@ function persistenceAdapterContract(options) {
|
|
|
284
419
|
test("bundle lifecycle roundtrips validity and auto-succeeds atomically", async (t) => {
|
|
285
420
|
const repository = harness.adapter.bundleRepository;
|
|
286
421
|
if (!repository?.findActiveBundleVersion) {
|
|
287
|
-
t
|
|
422
|
+
missing(t, "bundleValidity");
|
|
288
423
|
return;
|
|
289
424
|
}
|
|
290
425
|
const bundle = await repository.create({
|
|
@@ -343,7 +478,7 @@ function persistenceAdapterContract(options) {
|
|
|
343
478
|
const repository = harness.adapter.subscriptionBundleRepository;
|
|
344
479
|
const { seed } = harness;
|
|
345
480
|
if (!repository || !seed.createBundleVersion) {
|
|
346
|
-
t
|
|
481
|
+
missing(t, "bundleBookings");
|
|
347
482
|
return;
|
|
348
483
|
}
|
|
349
484
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -398,7 +533,7 @@ function persistenceAdapterContract(options) {
|
|
|
398
533
|
const repository = harness.adapter.subscriptionBundleRepository;
|
|
399
534
|
const { seed } = harness;
|
|
400
535
|
if (!repository || !seed.createBundleVersion) {
|
|
401
|
-
t
|
|
536
|
+
missing(t, "bundleBookings");
|
|
402
537
|
return;
|
|
403
538
|
}
|
|
404
539
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -452,7 +587,7 @@ function persistenceAdapterContract(options) {
|
|
|
452
587
|
const repository = harness.adapter.subscriptionBundleRepository;
|
|
453
588
|
const { seed } = harness;
|
|
454
589
|
if (!repository || !seed.createBundleVersion) {
|
|
455
|
-
t
|
|
590
|
+
missing(t, "bundleBookings");
|
|
456
591
|
return;
|
|
457
592
|
}
|
|
458
593
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -497,7 +632,7 @@ function persistenceAdapterContract(options) {
|
|
|
497
632
|
const repository = harness.adapter.subscriptionBundleRepository;
|
|
498
633
|
const { seed } = harness;
|
|
499
634
|
if (!repository || !seed.createBundleVersion) {
|
|
500
|
-
t
|
|
635
|
+
missing(t, "bundleBookings");
|
|
501
636
|
return;
|
|
502
637
|
}
|
|
503
638
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -528,7 +663,7 @@ function persistenceAdapterContract(options) {
|
|
|
528
663
|
});
|
|
529
664
|
const clearRequestDate = harness.seed.clearBookingRequestDate;
|
|
530
665
|
if (!clearRequestDate) {
|
|
531
|
-
t
|
|
666
|
+
missing(t, "halfCancelledBookingSeed");
|
|
532
667
|
return;
|
|
533
668
|
}
|
|
534
669
|
await clearRequestDate(booking.id);
|
|
@@ -549,7 +684,7 @@ function persistenceAdapterContract(options) {
|
|
|
549
684
|
const catalog = harness.adapter.bundleRepository;
|
|
550
685
|
const discardDraft = catalog?.deleteDraft?.bind(catalog);
|
|
551
686
|
if (!catalog || !discardDraft) {
|
|
552
|
-
t
|
|
687
|
+
missing(t, "bundleDraftDiscard");
|
|
553
688
|
return;
|
|
554
689
|
}
|
|
555
690
|
const bundle = await catalog.create({
|
|
@@ -581,7 +716,7 @@ function persistenceAdapterContract(options) {
|
|
|
581
716
|
const catalog = harness.adapter.bundleRepository;
|
|
582
717
|
const publish = catalog?.publishDraft?.bind(catalog);
|
|
583
718
|
if (!catalog || !publish) {
|
|
584
|
-
t
|
|
719
|
+
missing(t, "bundleDraftPublish");
|
|
585
720
|
return;
|
|
586
721
|
}
|
|
587
722
|
const bundle = await catalog.create({
|
|
@@ -644,7 +779,7 @@ function persistenceAdapterContract(options) {
|
|
|
644
779
|
test("a plan key names one plan for the whole installation", async (t) => {
|
|
645
780
|
const repository = harness.adapter.planRepository;
|
|
646
781
|
if (!repository) {
|
|
647
|
-
t
|
|
782
|
+
missing(t, "planRepository");
|
|
648
783
|
return;
|
|
649
784
|
}
|
|
650
785
|
await repository.create({ planKey: "DOUBLE", label: "First" });
|
|
@@ -656,7 +791,7 @@ function persistenceAdapterContract(options) {
|
|
|
656
791
|
test("a bundle key names one bundle for the whole installation", async (t) => {
|
|
657
792
|
const catalog = harness.adapter.bundleRepository;
|
|
658
793
|
if (!catalog) {
|
|
659
|
-
t
|
|
794
|
+
missing(t, "bundleRepository");
|
|
660
795
|
return;
|
|
661
796
|
}
|
|
662
797
|
await catalog.create({ bundleKey: "DOUBLE", label: "First" });
|
|
@@ -670,7 +805,7 @@ function persistenceAdapterContract(options) {
|
|
|
670
805
|
const retire = repository?.softDelete?.bind(repository);
|
|
671
806
|
const byKey = repository?.findByKey?.bind(repository);
|
|
672
807
|
if (!repository || !retire || !byKey) {
|
|
673
|
-
t
|
|
808
|
+
missing(t, "planRetirement");
|
|
674
809
|
return;
|
|
675
810
|
}
|
|
676
811
|
const plan = await repository.create({ planKey: "RETIRED_PLAN", label: "Retired" });
|
|
@@ -684,12 +819,90 @@ function persistenceAdapterContract(options) {
|
|
|
684
819
|
"a retired plan is not in the catalogue an operator browses"
|
|
685
820
|
);
|
|
686
821
|
});
|
|
822
|
+
test("a plan key no plan has finds no versions, rather than failing", async (t) => {
|
|
823
|
+
const repository = harness.adapter.planRepository;
|
|
824
|
+
if (!repository?.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion) {
|
|
825
|
+
missing(t, "planVersionReads");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const entitlementVersions = harness.adapter.planVersionRepository;
|
|
829
|
+
assert.deepEqual(await repository.listVersions("NO_SUCH_PLAN"), []);
|
|
830
|
+
assert.equal(await repository.findCurrentDraft("NO_SUCH_PLAN"), null);
|
|
831
|
+
assert.equal(await repository.findLatestLivePlanVersion("NO_SUCH_PLAN"), null);
|
|
832
|
+
if (repository.findActivePlanVersion) {
|
|
833
|
+
assert.equal(
|
|
834
|
+
await repository.findActivePlanVersion("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
|
|
835
|
+
null
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
assert.equal(await entitlementVersions.findLatestLive("NO_SUCH_PLAN"), null);
|
|
839
|
+
if (entitlementVersions.findActive) {
|
|
840
|
+
assert.equal(
|
|
841
|
+
await entitlementVersions.findActive("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
|
|
842
|
+
null
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
test("retiring a plan hides none of its versions", async (t) => {
|
|
847
|
+
const repository = harness.adapter.planRepository;
|
|
848
|
+
if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion || !repository.softDelete) {
|
|
849
|
+
missing(t, "planVersionRetirement");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const listVersions = repository.listVersions.bind(repository);
|
|
853
|
+
const findCurrentDraft = repository.findCurrentDraft.bind(repository);
|
|
854
|
+
const findLatestLive = repository.findLatestLivePlanVersion.bind(repository);
|
|
855
|
+
const entitlementVersions = harness.adapter.planVersionRepository;
|
|
856
|
+
const plan = await repository.create({ planKey: "RETIRING", label: "Retiring" });
|
|
857
|
+
const firstDraft = await repository.createPlanVersionDraft({
|
|
858
|
+
planId: "RETIRING",
|
|
859
|
+
features: ["CORE"],
|
|
860
|
+
quotas: { users: 5 },
|
|
861
|
+
monthlyNet: "10.00",
|
|
862
|
+
yearlyNet: "100.00",
|
|
863
|
+
validFrom: "2026-01-01"
|
|
864
|
+
});
|
|
865
|
+
const live = await repository.publishPlanVersionDraft(firstDraft.id, {
|
|
866
|
+
publishedByUserId: null,
|
|
867
|
+
publishedChanges: [],
|
|
868
|
+
nonRegressive: true,
|
|
869
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
870
|
+
validUntil: null
|
|
871
|
+
});
|
|
872
|
+
const openDraft = await repository.createPlanVersionDraft({
|
|
873
|
+
planId: "RETIRING",
|
|
874
|
+
baseVersionId: live.id,
|
|
875
|
+
features: ["CORE", "PLUS"],
|
|
876
|
+
quotas: { users: 10 },
|
|
877
|
+
monthlyNet: "15.00",
|
|
878
|
+
yearlyNet: "150.00",
|
|
879
|
+
validFrom: "2026-06-01"
|
|
880
|
+
});
|
|
881
|
+
const reads = async () => ({
|
|
882
|
+
versions: (await listVersions("RETIRING")).map((row) => row.id),
|
|
883
|
+
draft: (await findCurrentDraft("RETIRING"))?.id ?? null,
|
|
884
|
+
latestLive: (await findLatestLive("RETIRING"))?.id ?? null,
|
|
885
|
+
entitlementFeatures: (await entitlementVersions.findLatestLive("RETIRING"))?.features ?? null
|
|
886
|
+
});
|
|
887
|
+
const beforeRetiring = await reads();
|
|
888
|
+
assert.deepEqual(
|
|
889
|
+
{
|
|
890
|
+
versions: beforeRetiring.versions,
|
|
891
|
+
draft: beforeRetiring.draft,
|
|
892
|
+
latestLive: beforeRetiring.latestLive
|
|
893
|
+
},
|
|
894
|
+
{ versions: [live.id, openDraft.id], draft: openDraft.id, latestLive: live.id },
|
|
895
|
+
"a live plan reads its versions, so the comparison below has a subject"
|
|
896
|
+
);
|
|
897
|
+
await repository.softDelete(plan.id);
|
|
898
|
+
assert.deepEqual(await reads(), beforeRetiring, "retiring the plan hid its versions");
|
|
899
|
+
});
|
|
687
900
|
test("a retired bundle still occupies its key", async (t) => {
|
|
688
901
|
const catalog = harness.adapter.bundleRepository;
|
|
689
902
|
const retire = catalog?.softDelete?.bind(catalog);
|
|
690
903
|
const byKey = catalog?.findByKey?.bind(catalog);
|
|
691
904
|
if (!catalog || !retire || !byKey) {
|
|
692
|
-
t
|
|
905
|
+
missing(t, "bundleRetirement");
|
|
693
906
|
return;
|
|
694
907
|
}
|
|
695
908
|
const bundle = await catalog.create({
|
|
@@ -711,7 +924,7 @@ function persistenceAdapterContract(options) {
|
|
|
711
924
|
test("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
|
|
712
925
|
const { seed, adapter } = harness;
|
|
713
926
|
if (!adapter.subscriptionRepository.countByPlanVersionId) {
|
|
714
|
-
t
|
|
927
|
+
missing(t, "countByPlanVersionId");
|
|
715
928
|
return;
|
|
716
929
|
}
|
|
717
930
|
const v1 = await seed.createPlanVersion({
|
|
@@ -751,9 +964,7 @@ function persistenceAdapterContract(options) {
|
|
|
751
964
|
return;
|
|
752
965
|
}
|
|
753
966
|
if (!adapter.promoCodeRedemptionRepository) {
|
|
754
|
-
t
|
|
755
|
-
"adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)"
|
|
756
|
-
);
|
|
967
|
+
missing(t, "promoCodeRedemptions");
|
|
757
968
|
return;
|
|
758
969
|
}
|
|
759
970
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
@@ -836,7 +1047,7 @@ function persistenceAdapterContract(options) {
|
|
|
836
1047
|
test("concurrent claimSlot grants exactly maxRedemptions slots", async (t) => {
|
|
837
1048
|
const { seed, adapter } = harness;
|
|
838
1049
|
if (!adapter.promoCodeRepository) {
|
|
839
|
-
t
|
|
1050
|
+
missing(t, "promoCodes");
|
|
840
1051
|
return;
|
|
841
1052
|
}
|
|
842
1053
|
const promoCodes = adapter.promoCodeRepository;
|
|
@@ -857,7 +1068,7 @@ function persistenceAdapterContract(options) {
|
|
|
857
1068
|
test("claimSlot / markExhaustedIfFull / releaseSlot lifecycle", async (t) => {
|
|
858
1069
|
const { seed, adapter } = harness;
|
|
859
1070
|
if (!adapter.promoCodeRepository) {
|
|
860
|
-
t
|
|
1071
|
+
missing(t, "promoCodes");
|
|
861
1072
|
return;
|
|
862
1073
|
}
|
|
863
1074
|
const promoCodes = adapter.promoCodeRepository;
|
|
@@ -877,7 +1088,7 @@ function persistenceAdapterContract(options) {
|
|
|
877
1088
|
test("a subscription cannot redeem twice (unique guard)", async (t) => {
|
|
878
1089
|
const { seed, adapter } = harness;
|
|
879
1090
|
if (!adapter.promoCodeRedemptionRepository) {
|
|
880
|
-
t
|
|
1091
|
+
missing(t, "promoCodeRedemptions");
|
|
881
1092
|
return;
|
|
882
1093
|
}
|
|
883
1094
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
@@ -917,7 +1128,7 @@ function persistenceAdapterContract(options) {
|
|
|
917
1128
|
test("audit write \u2192 query roundtrip with actorTag filters", async (t) => {
|
|
918
1129
|
const { adapter } = harness;
|
|
919
1130
|
if (!adapter.audit || !adapter.auditQuery) {
|
|
920
|
-
t
|
|
1131
|
+
missing(t, "audit");
|
|
921
1132
|
return;
|
|
922
1133
|
}
|
|
923
1134
|
await adapter.audit.write({
|
|
@@ -957,7 +1168,7 @@ function persistenceAdapterContract(options) {
|
|
|
957
1168
|
test("MFA secret roundtrip", async (t) => {
|
|
958
1169
|
const { adapter } = harness;
|
|
959
1170
|
if (!adapter.mfa) {
|
|
960
|
-
t
|
|
1171
|
+
missing(t, "mfa");
|
|
961
1172
|
return;
|
|
962
1173
|
}
|
|
963
1174
|
assert.equal(await adapter.mfa.getSecret("admin-1"), null);
|
|
@@ -972,7 +1183,7 @@ function persistenceAdapterContract(options) {
|
|
|
972
1183
|
test("finds the subscription the id names, not merely a subscription", async (t) => {
|
|
973
1184
|
const { adapter, seed } = harness;
|
|
974
1185
|
if (!adapter.promoSubscriptionLookup) {
|
|
975
|
-
t
|
|
1186
|
+
missing(t, "promoSubscriptionLookup");
|
|
976
1187
|
return;
|
|
977
1188
|
}
|
|
978
1189
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -1001,7 +1212,7 @@ function persistenceAdapterContract(options) {
|
|
|
1001
1212
|
test("returns null for an id that does not exist", async (t) => {
|
|
1002
1213
|
const { adapter, seed } = harness;
|
|
1003
1214
|
if (!adapter.promoSubscriptionLookup) {
|
|
1004
|
-
t
|
|
1215
|
+
missing(t, "promoSubscriptionLookup");
|
|
1005
1216
|
return;
|
|
1006
1217
|
}
|
|
1007
1218
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -1022,7 +1233,7 @@ function persistenceAdapterContract(options) {
|
|
|
1022
1233
|
test("carries the fields a promo rule reads: cycle and start date", async (t) => {
|
|
1023
1234
|
const { adapter, seed } = harness;
|
|
1024
1235
|
if (!adapter.promoSubscriptionLookup) {
|
|
1025
|
-
t
|
|
1236
|
+
missing(t, "promoSubscriptionLookup");
|
|
1026
1237
|
return;
|
|
1027
1238
|
}
|
|
1028
1239
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -1057,7 +1268,7 @@ function persistenceAdapterContract(options) {
|
|
|
1057
1268
|
test("reads inside a transaction, so validation and redemption agree", async (t) => {
|
|
1058
1269
|
const { adapter, seed } = harness;
|
|
1059
1270
|
if (!adapter.promoSubscriptionLookup) {
|
|
1060
|
-
t
|
|
1271
|
+
missing(t, "promoSubscriptionLookup");
|
|
1061
1272
|
return;
|
|
1062
1273
|
}
|
|
1063
1274
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -1081,7 +1292,7 @@ function persistenceAdapterContract(options) {
|
|
|
1081
1292
|
test("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
|
|
1082
1293
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1083
1294
|
if (!contracts) {
|
|
1084
|
-
t
|
|
1295
|
+
missing(t, "subscriptionContracts");
|
|
1085
1296
|
return;
|
|
1086
1297
|
}
|
|
1087
1298
|
const tenantId = "tenant-contract-lifecycle";
|
|
@@ -1237,7 +1448,7 @@ function persistenceAdapterContract(options) {
|
|
|
1237
1448
|
test("a successor takes over without erasing the contract it replaces", async (t) => {
|
|
1238
1449
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1239
1450
|
if (!contracts) {
|
|
1240
|
-
t
|
|
1451
|
+
missing(t, "subscriptionContracts");
|
|
1241
1452
|
return;
|
|
1242
1453
|
}
|
|
1243
1454
|
const tenantId = "tenant-contract-succession";
|
|
@@ -1339,7 +1550,7 @@ function persistenceAdapterContract(options) {
|
|
|
1339
1550
|
test("a line keeps the currency and the tax it was booked with", async (t) => {
|
|
1340
1551
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1341
1552
|
if (!contracts) {
|
|
1342
|
-
t
|
|
1553
|
+
missing(t, "subscriptionContracts");
|
|
1343
1554
|
return;
|
|
1344
1555
|
}
|
|
1345
1556
|
const tenantId = "tenant-contract-money-facts";
|
|
@@ -1437,7 +1648,7 @@ function persistenceAdapterContract(options) {
|
|
|
1437
1648
|
test("no record before the first boot that could write one", async (t) => {
|
|
1438
1649
|
const port = harness.adapter.appliedSettings;
|
|
1439
1650
|
if (!port) {
|
|
1440
|
-
t
|
|
1651
|
+
missing(t, "appliedSettings");
|
|
1441
1652
|
return;
|
|
1442
1653
|
}
|
|
1443
1654
|
assert.equal(await port.readApplied(), null);
|
|
@@ -1446,7 +1657,7 @@ function persistenceAdapterContract(options) {
|
|
|
1446
1657
|
test("the record comes back as it was written \u2014 values, source and moment", async (t) => {
|
|
1447
1658
|
const port = harness.adapter.appliedSettings;
|
|
1448
1659
|
if (!port) {
|
|
1449
|
-
t
|
|
1660
|
+
missing(t, "appliedSettings");
|
|
1450
1661
|
return;
|
|
1451
1662
|
}
|
|
1452
1663
|
assert.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
|
|
@@ -1460,7 +1671,7 @@ function persistenceAdapterContract(options) {
|
|
|
1460
1671
|
test("writing again replaces the one row rather than adding a second", async (t) => {
|
|
1461
1672
|
const port = harness.adapter.appliedSettings;
|
|
1462
1673
|
if (!port) {
|
|
1463
|
-
t
|
|
1674
|
+
missing(t, "appliedSettings");
|
|
1464
1675
|
return;
|
|
1465
1676
|
}
|
|
1466
1677
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
@@ -1476,7 +1687,7 @@ function persistenceAdapterContract(options) {
|
|
|
1476
1687
|
test("the first record is written once: a second writer that read none is refused", async (t) => {
|
|
1477
1688
|
const port = harness.adapter.appliedSettings;
|
|
1478
1689
|
if (!port) {
|
|
1479
|
-
t
|
|
1690
|
+
missing(t, "appliedSettings");
|
|
1480
1691
|
return;
|
|
1481
1692
|
}
|
|
1482
1693
|
assert.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
|
|
@@ -1486,7 +1697,7 @@ function persistenceAdapterContract(options) {
|
|
|
1486
1697
|
test("a write guarded on a fingerprint the row no longer carries is refused", async (t) => {
|
|
1487
1698
|
const port = harness.adapter.appliedSettings;
|
|
1488
1699
|
if (!port) {
|
|
1489
|
-
t
|
|
1700
|
+
missing(t, "appliedSettings");
|
|
1490
1701
|
return;
|
|
1491
1702
|
}
|
|
1492
1703
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
@@ -1509,7 +1720,7 @@ function persistenceAdapterContract(options) {
|
|
|
1509
1720
|
test("a change lands with the record it supersedes, and is listed newest first", async (t) => {
|
|
1510
1721
|
const port = harness.adapter.appliedSettings;
|
|
1511
1722
|
if (!port) {
|
|
1512
|
-
t
|
|
1723
|
+
missing(t, "appliedSettings");
|
|
1513
1724
|
return;
|
|
1514
1725
|
}
|
|
1515
1726
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
@@ -1547,7 +1758,7 @@ function persistenceAdapterContract(options) {
|
|
|
1547
1758
|
test("a change whose record has moved on is refused whole: no change, and the record as it was", async (t) => {
|
|
1548
1759
|
const port = harness.adapter.appliedSettings;
|
|
1549
1760
|
if (!port) {
|
|
1550
|
-
t
|
|
1761
|
+
missing(t, "appliedSettings");
|
|
1551
1762
|
return;
|
|
1552
1763
|
}
|
|
1553
1764
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
@@ -1565,7 +1776,7 @@ function persistenceAdapterContract(options) {
|
|
|
1565
1776
|
test("starts noticing the same difference at once record it once", async (t) => {
|
|
1566
1777
|
const port = harness.adapter.appliedSettings;
|
|
1567
1778
|
if (!port) {
|
|
1568
|
-
t
|
|
1779
|
+
missing(t, "appliedSettings");
|
|
1569
1780
|
return;
|
|
1570
1781
|
}
|
|
1571
1782
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
@@ -1589,7 +1800,7 @@ function persistenceAdapterContract(options) {
|
|
|
1589
1800
|
test("several first starts write the record once", async (t) => {
|
|
1590
1801
|
const port = harness.adapter.appliedSettings;
|
|
1591
1802
|
if (!port) {
|
|
1592
|
-
t
|
|
1803
|
+
missing(t, "appliedSettings");
|
|
1593
1804
|
return;
|
|
1594
1805
|
}
|
|
1595
1806
|
const written = await Promise.all(
|
|
@@ -1601,7 +1812,7 @@ function persistenceAdapterContract(options) {
|
|
|
1601
1812
|
test("changes are listed in the order they were recorded, latest first \u2014 not by the moment they carry", async (t) => {
|
|
1602
1813
|
const port = harness.adapter.appliedSettings;
|
|
1603
1814
|
if (!port) {
|
|
1604
|
-
t
|
|
1815
|
+
missing(t, "appliedSettings");
|
|
1605
1816
|
return;
|
|
1606
1817
|
}
|
|
1607
1818
|
await port.writeApplied(applied("sha256-0", FIRST_START, {}), null);
|
|
@@ -1633,7 +1844,7 @@ function persistenceAdapterContract(options) {
|
|
|
1633
1844
|
test("acknowledging a change is recorded once, and filters it out of what is owed", async (t) => {
|
|
1634
1845
|
const port = harness.adapter.appliedSettings;
|
|
1635
1846
|
if (!port) {
|
|
1636
|
-
t
|
|
1847
|
+
missing(t, "appliedSettings");
|
|
1637
1848
|
return;
|
|
1638
1849
|
}
|
|
1639
1850
|
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saasicat/persistence-testing",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.14",
|
|
4
4
|
"description": "Contract test kit for SaaSiCat persistence adapters: one node:test suite that every adapter (Prisma, Drizzle, ...) must pass against a real database — locks, transaction rollback, atomic promo claims, tenant isolation, audit/MFA roundtrips.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"dist"
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@saasicat/core": "^1.0.0-rc.
|
|
26
|
+
"@saasicat/core": "^1.0.0-rc.14"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^25.6.0",
|