@saasicat/persistence-testing 0.5.0 → 0.7.0
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 +9 -2
- package/dist/index.cjs +266 -3
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +266 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,6 +9,9 @@ Verified scenarios:
|
|
|
9
9
|
|
|
10
10
|
- tenant subscription lookup + tenant isolation
|
|
11
11
|
- plan-version resolution (live vs. superseded vs. draft)
|
|
12
|
+
- semantic plan-key mapping at the port boundary
|
|
13
|
+
- atomic plan + `planVersionId` changes and tx-bound onboarding promo rollback
|
|
14
|
+
- PlanVersion and BundleVersion validity windows with auto-succession
|
|
12
15
|
- `countByPlanVersionId` counts current AND pending bindings in one query
|
|
13
16
|
- transaction rollback discards writes
|
|
14
17
|
- `findByTenantIdLocked` serializes concurrent transactions (row lock)
|
|
@@ -30,17 +33,21 @@ import { persistenceAdapterContract } from '@saasicat/persistence-testing';
|
|
|
30
33
|
|
|
31
34
|
persistenceAdapterContract({
|
|
32
35
|
name: 'adapter-drizzle @ postgres',
|
|
36
|
+
projectKey: 'my-app',
|
|
33
37
|
create: async () => ({
|
|
34
38
|
adapter: {
|
|
35
|
-
capabilities: { transactions: true, pessimisticLocking: true
|
|
39
|
+
capabilities: { transactions: true, pessimisticLocking: true /* … */ },
|
|
36
40
|
transactionRunner,
|
|
37
41
|
subscriptionRepository,
|
|
38
42
|
planVersionRepository,
|
|
39
|
-
promoCodeRepository,
|
|
43
|
+
promoCodeRepository, // optional slices activate more scenarios
|
|
40
44
|
promoCodeRedemptionRepository,
|
|
41
45
|
mfa,
|
|
42
46
|
audit,
|
|
43
47
|
auditQuery,
|
|
48
|
+
tenantSubscriptionWrite, // optional: enables atomic plan-binding scenarios
|
|
49
|
+
planRepository, // optional: enables plan lifecycle scenarios
|
|
50
|
+
bundleRepository, // optional: enables bundle validity scenarios
|
|
44
51
|
},
|
|
45
52
|
seed: { createPlanVersion, createSubscription, createPromoCode },
|
|
46
53
|
reset: () => truncatePlatformTables(),
|
package/dist/index.cjs
CHANGED
|
@@ -117,8 +117,259 @@ function persistenceAdapterContract(options) {
|
|
|
117
117
|
});
|
|
118
118
|
const live = await adapter.planVersionRepository.findLatestLive("PRO");
|
|
119
119
|
import_strict.default.ok(live, "live version expected");
|
|
120
|
+
import_strict.default.equal(
|
|
121
|
+
live.planId,
|
|
122
|
+
"PRO",
|
|
123
|
+
"port-facing plan identity must remain the semantic plan key"
|
|
124
|
+
);
|
|
120
125
|
import_strict.default.deepEqual(live.quotas, { users: 2 });
|
|
121
126
|
});
|
|
127
|
+
(0, import_node_test.test)("immediate plan change binds plan and active PlanVersion consistently", async (t) => {
|
|
128
|
+
const { seed, adapter } = harness;
|
|
129
|
+
if (!adapter.tenantSubscriptionWrite) {
|
|
130
|
+
t.skip("adapter does not expose atomic plan-binding writes");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const oldVersion = await seed.createPlanVersion({
|
|
134
|
+
planKey: "STARTER",
|
|
135
|
+
version: 1,
|
|
136
|
+
quotas: { users: 2 },
|
|
137
|
+
features: [],
|
|
138
|
+
published: true
|
|
139
|
+
});
|
|
140
|
+
const targetVersion = await seed.createPlanVersion({
|
|
141
|
+
planKey: "PRO",
|
|
142
|
+
version: 1,
|
|
143
|
+
quotas: { users: 20 },
|
|
144
|
+
features: ["PRO"],
|
|
145
|
+
published: true
|
|
146
|
+
});
|
|
147
|
+
await seed.createSubscription({
|
|
148
|
+
tenantId: "tenant-plan-change",
|
|
149
|
+
plan: "STARTER",
|
|
150
|
+
planVersionId: oldVersion.planVersionId
|
|
151
|
+
});
|
|
152
|
+
await adapter.tenantSubscriptionWrite.changePlanImmediate("tenant-plan-change", {
|
|
153
|
+
planId: "PRO",
|
|
154
|
+
cycle: "YEARLY",
|
|
155
|
+
periodStart: null,
|
|
156
|
+
periodEnd: null,
|
|
157
|
+
nextStatus: null
|
|
158
|
+
});
|
|
159
|
+
const changed = await adapter.subscriptionRepository.findByTenantId("tenant-plan-change");
|
|
160
|
+
import_strict.default.ok(changed, "changed subscription expected");
|
|
161
|
+
import_strict.default.equal(changed.plan, "PRO");
|
|
162
|
+
import_strict.default.equal(changed.planVersionId, targetVersion.planVersionId);
|
|
163
|
+
import_strict.default.equal(changed.planVersion.planId, "PRO");
|
|
164
|
+
});
|
|
165
|
+
(0, import_node_test.test)("onboarding selection rolls plan binding and promo write back together", async (t) => {
|
|
166
|
+
const { seed, adapter } = harness;
|
|
167
|
+
const writer = adapter.tenantSubscriptionWrite;
|
|
168
|
+
if (!writer?.applyOnboardingSelection) {
|
|
169
|
+
t.skip("adapter does not expose atomic onboarding writes");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
173
|
+
if (!redemptions) {
|
|
174
|
+
t.skip("adapter provides no PromoCodeRedemptionRepository");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const oldVersion = await seed.createPlanVersion({
|
|
178
|
+
planKey: "STARTER",
|
|
179
|
+
version: 1,
|
|
180
|
+
quotas: {},
|
|
181
|
+
features: [],
|
|
182
|
+
published: true
|
|
183
|
+
});
|
|
184
|
+
await seed.createPlanVersion({
|
|
185
|
+
planKey: "PRO",
|
|
186
|
+
version: 1,
|
|
187
|
+
quotas: {},
|
|
188
|
+
features: [],
|
|
189
|
+
published: true
|
|
190
|
+
});
|
|
191
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
192
|
+
tenantId: "tenant-onboarding-rollback",
|
|
193
|
+
plan: "STARTER",
|
|
194
|
+
planVersionId: oldVersion.planVersionId
|
|
195
|
+
});
|
|
196
|
+
const { promoCodeId } = await seed.createPromoCode({
|
|
197
|
+
code: "ONBOARDING-ROLLBACK",
|
|
198
|
+
maxRedemptions: null
|
|
199
|
+
});
|
|
200
|
+
const startsAt = /* @__PURE__ */ new Date("2026-07-24T00:00:00.000Z");
|
|
201
|
+
await import_strict.default.rejects(
|
|
202
|
+
writer.applyOnboardingSelection(
|
|
203
|
+
"tenant-onboarding-rollback",
|
|
204
|
+
{
|
|
205
|
+
planId: "PRO",
|
|
206
|
+
cycle: "MONTHLY",
|
|
207
|
+
periodStart: null,
|
|
208
|
+
periodEnd: null,
|
|
209
|
+
nextStatus: null
|
|
210
|
+
},
|
|
211
|
+
async (tx, callbackSubscriptionId) => {
|
|
212
|
+
import_strict.default.equal(callbackSubscriptionId, subscriptionId);
|
|
213
|
+
await redemptions.create(
|
|
214
|
+
{
|
|
215
|
+
promoCodeId,
|
|
216
|
+
subscriptionId: callbackSubscriptionId,
|
|
217
|
+
tenantId: "tenant-onboarding-rollback",
|
|
218
|
+
appliedValueType: "PERCENT",
|
|
219
|
+
appliedValue: "10.00",
|
|
220
|
+
appliedDurationType: "ONCE",
|
|
221
|
+
appliedDurationValue: null,
|
|
222
|
+
startsAt,
|
|
223
|
+
endsAt: null
|
|
224
|
+
},
|
|
225
|
+
tx
|
|
226
|
+
);
|
|
227
|
+
import_strict.default.ok(
|
|
228
|
+
await redemptions.findBySubscription(callbackSubscriptionId, tx),
|
|
229
|
+
"promo write must be visible inside the onboarding transaction"
|
|
230
|
+
);
|
|
231
|
+
throw new Error("promo redemption failed");
|
|
232
|
+
}
|
|
233
|
+
),
|
|
234
|
+
/promo redemption failed/
|
|
235
|
+
);
|
|
236
|
+
const unchanged = await adapter.subscriptionRepository.findByTenantId(
|
|
237
|
+
"tenant-onboarding-rollback"
|
|
238
|
+
);
|
|
239
|
+
import_strict.default.ok(unchanged, "subscription expected after rollback");
|
|
240
|
+
import_strict.default.equal(unchanged.plan, "STARTER");
|
|
241
|
+
import_strict.default.equal(unchanged.planVersionId, oldVersion.planVersionId);
|
|
242
|
+
import_strict.default.equal(unchanged.planVersion.planId, "STARTER");
|
|
243
|
+
import_strict.default.equal(
|
|
244
|
+
await redemptions.findBySubscription(subscriptionId),
|
|
245
|
+
null,
|
|
246
|
+
"promo callback write must be rolled back"
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
(0, import_node_test.test)("plan lifecycle keeps semantic identity and auto-succeeds validity windows", async (t) => {
|
|
250
|
+
const repository = harness.adapter.planRepository;
|
|
251
|
+
if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.findVersionById || !repository.findActivePlanVersion) {
|
|
252
|
+
t.skip("adapter provides no time-aware PlanRepository lifecycle");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const plan = await repository.create({
|
|
256
|
+
projectKey: options.projectKey,
|
|
257
|
+
planKey: "STANDARD",
|
|
258
|
+
label: "Standard"
|
|
259
|
+
});
|
|
260
|
+
import_strict.default.equal(plan.projectKey, options.projectKey);
|
|
261
|
+
const firstDraft = await repository.createPlanVersionDraft({
|
|
262
|
+
planId: "STANDARD",
|
|
263
|
+
features: ["CORE"],
|
|
264
|
+
quotas: { users: 5 },
|
|
265
|
+
monthlyNet: "10.00",
|
|
266
|
+
yearlyNet: "100.00",
|
|
267
|
+
validFrom: "2026-01-01"
|
|
268
|
+
});
|
|
269
|
+
import_strict.default.equal(firstDraft.planId, "STANDARD");
|
|
270
|
+
const first = await repository.publishPlanVersionDraft(firstDraft.id, {
|
|
271
|
+
publishedByUserId: null,
|
|
272
|
+
publishedChanges: [],
|
|
273
|
+
nonRegressive: true,
|
|
274
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
275
|
+
validUntil: null
|
|
276
|
+
});
|
|
277
|
+
const secondDraft = await repository.createPlanVersionDraft({
|
|
278
|
+
planId: "STANDARD",
|
|
279
|
+
baseVersionId: first.id,
|
|
280
|
+
features: ["CORE", "PLUS"],
|
|
281
|
+
quotas: { users: 10 },
|
|
282
|
+
monthlyNet: "15.00",
|
|
283
|
+
yearlyNet: "150.00",
|
|
284
|
+
validFrom: "2026-03-01"
|
|
285
|
+
});
|
|
286
|
+
const second = await repository.publishPlanVersionDraft(secondDraft.id, {
|
|
287
|
+
publishedByUserId: null,
|
|
288
|
+
publishedChanges: [],
|
|
289
|
+
nonRegressive: true,
|
|
290
|
+
validFrom: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
291
|
+
validUntil: null
|
|
292
|
+
});
|
|
293
|
+
const succeeded = await repository.findVersionById(first.id);
|
|
294
|
+
import_strict.default.ok(succeeded, "predecessor expected");
|
|
295
|
+
import_strict.default.ok(succeeded.supersededAt, "predecessor must be superseded");
|
|
296
|
+
import_strict.default.equal(succeeded.validUntil, "2026-02-28T00:00:00.000Z");
|
|
297
|
+
import_strict.default.equal(succeeded.planId, "STANDARD");
|
|
298
|
+
import_strict.default.equal(
|
|
299
|
+
(await repository.findActivePlanVersion(
|
|
300
|
+
"STANDARD",
|
|
301
|
+
/* @__PURE__ */ new Date("2026-02-28T23:59:59.999Z")
|
|
302
|
+
))?.id,
|
|
303
|
+
first.id,
|
|
304
|
+
"validUntil is day-inclusive"
|
|
305
|
+
);
|
|
306
|
+
import_strict.default.equal(
|
|
307
|
+
(await repository.findActivePlanVersion(
|
|
308
|
+
"STANDARD",
|
|
309
|
+
/* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z")
|
|
310
|
+
))?.id,
|
|
311
|
+
second.id
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
(0, import_node_test.test)("bundle lifecycle roundtrips validity and auto-succeeds atomically", async (t) => {
|
|
315
|
+
const repository = harness.adapter.bundleRepository;
|
|
316
|
+
if (!repository?.findActiveBundleVersion) {
|
|
317
|
+
t.skip("adapter provides no time-aware BundleRepository");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const bundle = await repository.create({
|
|
321
|
+
projectKey: options.projectKey,
|
|
322
|
+
bundleKey: "REPORTING",
|
|
323
|
+
label: "Reporting"
|
|
324
|
+
});
|
|
325
|
+
import_strict.default.equal(bundle.projectKey, options.projectKey);
|
|
326
|
+
const firstDraft = await repository.createDraft({
|
|
327
|
+
bundleId: bundle.id,
|
|
328
|
+
features: ["REPORTS"],
|
|
329
|
+
quotas: {},
|
|
330
|
+
validFrom: "2026-01-01"
|
|
331
|
+
});
|
|
332
|
+
const first = await repository.publishDraft(firstDraft.id, {
|
|
333
|
+
publishedByUserId: null,
|
|
334
|
+
publishedChanges: [],
|
|
335
|
+
nonRegressive: true,
|
|
336
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
337
|
+
validUntil: null
|
|
338
|
+
});
|
|
339
|
+
const secondDraft = await repository.createDraft({
|
|
340
|
+
bundleId: bundle.id,
|
|
341
|
+
baseVersionId: first.id,
|
|
342
|
+
features: ["REPORTS", "EXPORTS"],
|
|
343
|
+
quotas: {},
|
|
344
|
+
validFrom: "2026-03-01"
|
|
345
|
+
});
|
|
346
|
+
const second = await repository.publishDraft(secondDraft.id, {
|
|
347
|
+
publishedByUserId: null,
|
|
348
|
+
publishedChanges: [],
|
|
349
|
+
nonRegressive: true,
|
|
350
|
+
validFrom: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
351
|
+
validUntil: null
|
|
352
|
+
});
|
|
353
|
+
const succeeded = await repository.findVersionById(first.id);
|
|
354
|
+
import_strict.default.ok(succeeded, "predecessor expected");
|
|
355
|
+
import_strict.default.ok(succeeded.supersededAt, "predecessor must be superseded");
|
|
356
|
+
import_strict.default.equal(succeeded.validUntil, "2026-02-28T00:00:00.000Z");
|
|
357
|
+
import_strict.default.equal(
|
|
358
|
+
(await repository.findActiveBundleVersion(
|
|
359
|
+
bundle.id,
|
|
360
|
+
/* @__PURE__ */ new Date("2026-02-28T23:59:59.999Z")
|
|
361
|
+
))?.id,
|
|
362
|
+
first.id,
|
|
363
|
+
"validUntil is day-inclusive"
|
|
364
|
+
);
|
|
365
|
+
import_strict.default.equal(
|
|
366
|
+
(await repository.findActiveBundleVersion(
|
|
367
|
+
bundle.id,
|
|
368
|
+
/* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z")
|
|
369
|
+
))?.id,
|
|
370
|
+
second.id
|
|
371
|
+
);
|
|
372
|
+
});
|
|
122
373
|
(0, import_node_test.test)("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
|
|
123
374
|
const { seed, adapter } = harness;
|
|
124
375
|
if (!adapter.subscriptionRepository.countByPlanVersionId) {
|
|
@@ -162,7 +413,9 @@ function persistenceAdapterContract(options) {
|
|
|
162
413
|
return;
|
|
163
414
|
}
|
|
164
415
|
if (!adapter.promoCodeRedemptionRepository) {
|
|
165
|
-
t.skip(
|
|
416
|
+
t.skip(
|
|
417
|
+
"adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)"
|
|
418
|
+
);
|
|
166
419
|
return;
|
|
167
420
|
}
|
|
168
421
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
@@ -330,14 +583,24 @@ function persistenceAdapterContract(options) {
|
|
|
330
583
|
return;
|
|
331
584
|
}
|
|
332
585
|
await adapter.audit.write({
|
|
333
|
-
actor: {
|
|
586
|
+
actor: {
|
|
587
|
+
userId: "admin-1",
|
|
588
|
+
email: "ops@example.com",
|
|
589
|
+
source: "cli",
|
|
590
|
+
context: "host1"
|
|
591
|
+
},
|
|
334
592
|
entity: "Tenant",
|
|
335
593
|
entityId: "tenant-a",
|
|
336
594
|
action: "TENANT_SUSPEND",
|
|
337
595
|
changes: { reason: "test" }
|
|
338
596
|
});
|
|
339
597
|
await adapter.audit.write({
|
|
340
|
-
actor: {
|
|
598
|
+
actor: {
|
|
599
|
+
userId: "admin-2",
|
|
600
|
+
email: "web@example.com",
|
|
601
|
+
source: "web",
|
|
602
|
+
context: "sess9"
|
|
603
|
+
},
|
|
341
604
|
entity: "PromoCode",
|
|
342
605
|
entityId: "promo-1",
|
|
343
606
|
action: "PROMO_CODE_CREATE"
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository } from '@saasicat/types';
|
|
1
|
+
import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, TenantSubscriptionWritePort, BundleRepository, PlanRepository } from '@saasicat/types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Port instances under test. Required members define the minimum an adapter
|
|
@@ -17,6 +17,16 @@ interface ContractAdapterInstances {
|
|
|
17
17
|
audit?: AuditPort;
|
|
18
18
|
auditQuery?: AuditQueryPort;
|
|
19
19
|
subscriptionContractRepository?: SubscriptionContractRepository;
|
|
20
|
+
/**
|
|
21
|
+
* Enables the atomic plan-binding scenarios. Adapters should expose this
|
|
22
|
+
* member only for a mode that promises to keep `plan`,
|
|
23
|
+
* `planVersionId` and pending-version state consistent.
|
|
24
|
+
*/
|
|
25
|
+
tenantSubscriptionWrite?: TenantSubscriptionWritePort;
|
|
26
|
+
/** Enables BundleVersion validity-window and auto-succession scenarios. */
|
|
27
|
+
bundleRepository?: BundleRepository;
|
|
28
|
+
/** Enables PlanVersion lifecycle, identity and validity-window scenarios. */
|
|
29
|
+
planRepository?: PlanRepository;
|
|
20
30
|
}
|
|
21
31
|
/** Fixture writers — implemented per adapter against its own schema. */
|
|
22
32
|
interface ContractSeed {
|
|
@@ -58,6 +68,8 @@ interface PersistenceContractHarness {
|
|
|
58
68
|
interface PersistenceAdapterContractOptions {
|
|
59
69
|
/** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
|
|
60
70
|
name: string;
|
|
71
|
+
/** Project identity used by catalog lifecycle scenarios. */
|
|
72
|
+
projectKey: string;
|
|
61
73
|
/** Builds the harness once for the whole suite. */
|
|
62
74
|
create(): Promise<PersistenceContractHarness>;
|
|
63
75
|
}
|
|
@@ -68,6 +80,7 @@ interface PersistenceAdapterContractOptions {
|
|
|
68
80
|
* ```ts
|
|
69
81
|
* persistenceAdapterContract({
|
|
70
82
|
* name: 'adapter-prisma @ postgres',
|
|
83
|
+
* projectKey: 'my-app',
|
|
71
84
|
* create: () => createPrismaHarness(),
|
|
72
85
|
* });
|
|
73
86
|
* ```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository } from '@saasicat/types';
|
|
1
|
+
import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, TenantSubscriptionWritePort, BundleRepository, PlanRepository } from '@saasicat/types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Port instances under test. Required members define the minimum an adapter
|
|
@@ -17,6 +17,16 @@ interface ContractAdapterInstances {
|
|
|
17
17
|
audit?: AuditPort;
|
|
18
18
|
auditQuery?: AuditQueryPort;
|
|
19
19
|
subscriptionContractRepository?: SubscriptionContractRepository;
|
|
20
|
+
/**
|
|
21
|
+
* Enables the atomic plan-binding scenarios. Adapters should expose this
|
|
22
|
+
* member only for a mode that promises to keep `plan`,
|
|
23
|
+
* `planVersionId` and pending-version state consistent.
|
|
24
|
+
*/
|
|
25
|
+
tenantSubscriptionWrite?: TenantSubscriptionWritePort;
|
|
26
|
+
/** Enables BundleVersion validity-window and auto-succession scenarios. */
|
|
27
|
+
bundleRepository?: BundleRepository;
|
|
28
|
+
/** Enables PlanVersion lifecycle, identity and validity-window scenarios. */
|
|
29
|
+
planRepository?: PlanRepository;
|
|
20
30
|
}
|
|
21
31
|
/** Fixture writers — implemented per adapter against its own schema. */
|
|
22
32
|
interface ContractSeed {
|
|
@@ -58,6 +68,8 @@ interface PersistenceContractHarness {
|
|
|
58
68
|
interface PersistenceAdapterContractOptions {
|
|
59
69
|
/** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
|
|
60
70
|
name: string;
|
|
71
|
+
/** Project identity used by catalog lifecycle scenarios. */
|
|
72
|
+
projectKey: string;
|
|
61
73
|
/** Builds the harness once for the whole suite. */
|
|
62
74
|
create(): Promise<PersistenceContractHarness>;
|
|
63
75
|
}
|
|
@@ -68,6 +80,7 @@ interface PersistenceAdapterContractOptions {
|
|
|
68
80
|
* ```ts
|
|
69
81
|
* persistenceAdapterContract({
|
|
70
82
|
* name: 'adapter-prisma @ postgres',
|
|
83
|
+
* projectKey: 'my-app',
|
|
71
84
|
* create: () => createPrismaHarness(),
|
|
72
85
|
* });
|
|
73
86
|
* ```
|
package/dist/index.js
CHANGED
|
@@ -81,8 +81,259 @@ function persistenceAdapterContract(options) {
|
|
|
81
81
|
});
|
|
82
82
|
const live = await adapter.planVersionRepository.findLatestLive("PRO");
|
|
83
83
|
assert.ok(live, "live version expected");
|
|
84
|
+
assert.equal(
|
|
85
|
+
live.planId,
|
|
86
|
+
"PRO",
|
|
87
|
+
"port-facing plan identity must remain the semantic plan key"
|
|
88
|
+
);
|
|
84
89
|
assert.deepEqual(live.quotas, { users: 2 });
|
|
85
90
|
});
|
|
91
|
+
test("immediate plan change binds plan and active PlanVersion consistently", async (t) => {
|
|
92
|
+
const { seed, adapter } = harness;
|
|
93
|
+
if (!adapter.tenantSubscriptionWrite) {
|
|
94
|
+
t.skip("adapter does not expose atomic plan-binding writes");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const oldVersion = await seed.createPlanVersion({
|
|
98
|
+
planKey: "STARTER",
|
|
99
|
+
version: 1,
|
|
100
|
+
quotas: { users: 2 },
|
|
101
|
+
features: [],
|
|
102
|
+
published: true
|
|
103
|
+
});
|
|
104
|
+
const targetVersion = await seed.createPlanVersion({
|
|
105
|
+
planKey: "PRO",
|
|
106
|
+
version: 1,
|
|
107
|
+
quotas: { users: 20 },
|
|
108
|
+
features: ["PRO"],
|
|
109
|
+
published: true
|
|
110
|
+
});
|
|
111
|
+
await seed.createSubscription({
|
|
112
|
+
tenantId: "tenant-plan-change",
|
|
113
|
+
plan: "STARTER",
|
|
114
|
+
planVersionId: oldVersion.planVersionId
|
|
115
|
+
});
|
|
116
|
+
await adapter.tenantSubscriptionWrite.changePlanImmediate("tenant-plan-change", {
|
|
117
|
+
planId: "PRO",
|
|
118
|
+
cycle: "YEARLY",
|
|
119
|
+
periodStart: null,
|
|
120
|
+
periodEnd: null,
|
|
121
|
+
nextStatus: null
|
|
122
|
+
});
|
|
123
|
+
const changed = await adapter.subscriptionRepository.findByTenantId("tenant-plan-change");
|
|
124
|
+
assert.ok(changed, "changed subscription expected");
|
|
125
|
+
assert.equal(changed.plan, "PRO");
|
|
126
|
+
assert.equal(changed.planVersionId, targetVersion.planVersionId);
|
|
127
|
+
assert.equal(changed.planVersion.planId, "PRO");
|
|
128
|
+
});
|
|
129
|
+
test("onboarding selection rolls plan binding and promo write back together", async (t) => {
|
|
130
|
+
const { seed, adapter } = harness;
|
|
131
|
+
const writer = adapter.tenantSubscriptionWrite;
|
|
132
|
+
if (!writer?.applyOnboardingSelection) {
|
|
133
|
+
t.skip("adapter does not expose atomic onboarding writes");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
137
|
+
if (!redemptions) {
|
|
138
|
+
t.skip("adapter provides no PromoCodeRedemptionRepository");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const oldVersion = await seed.createPlanVersion({
|
|
142
|
+
planKey: "STARTER",
|
|
143
|
+
version: 1,
|
|
144
|
+
quotas: {},
|
|
145
|
+
features: [],
|
|
146
|
+
published: true
|
|
147
|
+
});
|
|
148
|
+
await seed.createPlanVersion({
|
|
149
|
+
planKey: "PRO",
|
|
150
|
+
version: 1,
|
|
151
|
+
quotas: {},
|
|
152
|
+
features: [],
|
|
153
|
+
published: true
|
|
154
|
+
});
|
|
155
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
156
|
+
tenantId: "tenant-onboarding-rollback",
|
|
157
|
+
plan: "STARTER",
|
|
158
|
+
planVersionId: oldVersion.planVersionId
|
|
159
|
+
});
|
|
160
|
+
const { promoCodeId } = await seed.createPromoCode({
|
|
161
|
+
code: "ONBOARDING-ROLLBACK",
|
|
162
|
+
maxRedemptions: null
|
|
163
|
+
});
|
|
164
|
+
const startsAt = /* @__PURE__ */ new Date("2026-07-24T00:00:00.000Z");
|
|
165
|
+
await assert.rejects(
|
|
166
|
+
writer.applyOnboardingSelection(
|
|
167
|
+
"tenant-onboarding-rollback",
|
|
168
|
+
{
|
|
169
|
+
planId: "PRO",
|
|
170
|
+
cycle: "MONTHLY",
|
|
171
|
+
periodStart: null,
|
|
172
|
+
periodEnd: null,
|
|
173
|
+
nextStatus: null
|
|
174
|
+
},
|
|
175
|
+
async (tx, callbackSubscriptionId) => {
|
|
176
|
+
assert.equal(callbackSubscriptionId, subscriptionId);
|
|
177
|
+
await redemptions.create(
|
|
178
|
+
{
|
|
179
|
+
promoCodeId,
|
|
180
|
+
subscriptionId: callbackSubscriptionId,
|
|
181
|
+
tenantId: "tenant-onboarding-rollback",
|
|
182
|
+
appliedValueType: "PERCENT",
|
|
183
|
+
appliedValue: "10.00",
|
|
184
|
+
appliedDurationType: "ONCE",
|
|
185
|
+
appliedDurationValue: null,
|
|
186
|
+
startsAt,
|
|
187
|
+
endsAt: null
|
|
188
|
+
},
|
|
189
|
+
tx
|
|
190
|
+
);
|
|
191
|
+
assert.ok(
|
|
192
|
+
await redemptions.findBySubscription(callbackSubscriptionId, tx),
|
|
193
|
+
"promo write must be visible inside the onboarding transaction"
|
|
194
|
+
);
|
|
195
|
+
throw new Error("promo redemption failed");
|
|
196
|
+
}
|
|
197
|
+
),
|
|
198
|
+
/promo redemption failed/
|
|
199
|
+
);
|
|
200
|
+
const unchanged = await adapter.subscriptionRepository.findByTenantId(
|
|
201
|
+
"tenant-onboarding-rollback"
|
|
202
|
+
);
|
|
203
|
+
assert.ok(unchanged, "subscription expected after rollback");
|
|
204
|
+
assert.equal(unchanged.plan, "STARTER");
|
|
205
|
+
assert.equal(unchanged.planVersionId, oldVersion.planVersionId);
|
|
206
|
+
assert.equal(unchanged.planVersion.planId, "STARTER");
|
|
207
|
+
assert.equal(
|
|
208
|
+
await redemptions.findBySubscription(subscriptionId),
|
|
209
|
+
null,
|
|
210
|
+
"promo callback write must be rolled back"
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
test("plan lifecycle keeps semantic identity and auto-succeeds validity windows", async (t) => {
|
|
214
|
+
const repository = harness.adapter.planRepository;
|
|
215
|
+
if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.findVersionById || !repository.findActivePlanVersion) {
|
|
216
|
+
t.skip("adapter provides no time-aware PlanRepository lifecycle");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const plan = await repository.create({
|
|
220
|
+
projectKey: options.projectKey,
|
|
221
|
+
planKey: "STANDARD",
|
|
222
|
+
label: "Standard"
|
|
223
|
+
});
|
|
224
|
+
assert.equal(plan.projectKey, options.projectKey);
|
|
225
|
+
const firstDraft = await repository.createPlanVersionDraft({
|
|
226
|
+
planId: "STANDARD",
|
|
227
|
+
features: ["CORE"],
|
|
228
|
+
quotas: { users: 5 },
|
|
229
|
+
monthlyNet: "10.00",
|
|
230
|
+
yearlyNet: "100.00",
|
|
231
|
+
validFrom: "2026-01-01"
|
|
232
|
+
});
|
|
233
|
+
assert.equal(firstDraft.planId, "STANDARD");
|
|
234
|
+
const first = await repository.publishPlanVersionDraft(firstDraft.id, {
|
|
235
|
+
publishedByUserId: null,
|
|
236
|
+
publishedChanges: [],
|
|
237
|
+
nonRegressive: true,
|
|
238
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
239
|
+
validUntil: null
|
|
240
|
+
});
|
|
241
|
+
const secondDraft = await repository.createPlanVersionDraft({
|
|
242
|
+
planId: "STANDARD",
|
|
243
|
+
baseVersionId: first.id,
|
|
244
|
+
features: ["CORE", "PLUS"],
|
|
245
|
+
quotas: { users: 10 },
|
|
246
|
+
monthlyNet: "15.00",
|
|
247
|
+
yearlyNet: "150.00",
|
|
248
|
+
validFrom: "2026-03-01"
|
|
249
|
+
});
|
|
250
|
+
const second = await repository.publishPlanVersionDraft(secondDraft.id, {
|
|
251
|
+
publishedByUserId: null,
|
|
252
|
+
publishedChanges: [],
|
|
253
|
+
nonRegressive: true,
|
|
254
|
+
validFrom: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
255
|
+
validUntil: null
|
|
256
|
+
});
|
|
257
|
+
const succeeded = await repository.findVersionById(first.id);
|
|
258
|
+
assert.ok(succeeded, "predecessor expected");
|
|
259
|
+
assert.ok(succeeded.supersededAt, "predecessor must be superseded");
|
|
260
|
+
assert.equal(succeeded.validUntil, "2026-02-28T00:00:00.000Z");
|
|
261
|
+
assert.equal(succeeded.planId, "STANDARD");
|
|
262
|
+
assert.equal(
|
|
263
|
+
(await repository.findActivePlanVersion(
|
|
264
|
+
"STANDARD",
|
|
265
|
+
/* @__PURE__ */ new Date("2026-02-28T23:59:59.999Z")
|
|
266
|
+
))?.id,
|
|
267
|
+
first.id,
|
|
268
|
+
"validUntil is day-inclusive"
|
|
269
|
+
);
|
|
270
|
+
assert.equal(
|
|
271
|
+
(await repository.findActivePlanVersion(
|
|
272
|
+
"STANDARD",
|
|
273
|
+
/* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z")
|
|
274
|
+
))?.id,
|
|
275
|
+
second.id
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
test("bundle lifecycle roundtrips validity and auto-succeeds atomically", async (t) => {
|
|
279
|
+
const repository = harness.adapter.bundleRepository;
|
|
280
|
+
if (!repository?.findActiveBundleVersion) {
|
|
281
|
+
t.skip("adapter provides no time-aware BundleRepository");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const bundle = await repository.create({
|
|
285
|
+
projectKey: options.projectKey,
|
|
286
|
+
bundleKey: "REPORTING",
|
|
287
|
+
label: "Reporting"
|
|
288
|
+
});
|
|
289
|
+
assert.equal(bundle.projectKey, options.projectKey);
|
|
290
|
+
const firstDraft = await repository.createDraft({
|
|
291
|
+
bundleId: bundle.id,
|
|
292
|
+
features: ["REPORTS"],
|
|
293
|
+
quotas: {},
|
|
294
|
+
validFrom: "2026-01-01"
|
|
295
|
+
});
|
|
296
|
+
const first = await repository.publishDraft(firstDraft.id, {
|
|
297
|
+
publishedByUserId: null,
|
|
298
|
+
publishedChanges: [],
|
|
299
|
+
nonRegressive: true,
|
|
300
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
301
|
+
validUntil: null
|
|
302
|
+
});
|
|
303
|
+
const secondDraft = await repository.createDraft({
|
|
304
|
+
bundleId: bundle.id,
|
|
305
|
+
baseVersionId: first.id,
|
|
306
|
+
features: ["REPORTS", "EXPORTS"],
|
|
307
|
+
quotas: {},
|
|
308
|
+
validFrom: "2026-03-01"
|
|
309
|
+
});
|
|
310
|
+
const second = await repository.publishDraft(secondDraft.id, {
|
|
311
|
+
publishedByUserId: null,
|
|
312
|
+
publishedChanges: [],
|
|
313
|
+
nonRegressive: true,
|
|
314
|
+
validFrom: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
315
|
+
validUntil: null
|
|
316
|
+
});
|
|
317
|
+
const succeeded = await repository.findVersionById(first.id);
|
|
318
|
+
assert.ok(succeeded, "predecessor expected");
|
|
319
|
+
assert.ok(succeeded.supersededAt, "predecessor must be superseded");
|
|
320
|
+
assert.equal(succeeded.validUntil, "2026-02-28T00:00:00.000Z");
|
|
321
|
+
assert.equal(
|
|
322
|
+
(await repository.findActiveBundleVersion(
|
|
323
|
+
bundle.id,
|
|
324
|
+
/* @__PURE__ */ new Date("2026-02-28T23:59:59.999Z")
|
|
325
|
+
))?.id,
|
|
326
|
+
first.id,
|
|
327
|
+
"validUntil is day-inclusive"
|
|
328
|
+
);
|
|
329
|
+
assert.equal(
|
|
330
|
+
(await repository.findActiveBundleVersion(
|
|
331
|
+
bundle.id,
|
|
332
|
+
/* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z")
|
|
333
|
+
))?.id,
|
|
334
|
+
second.id
|
|
335
|
+
);
|
|
336
|
+
});
|
|
86
337
|
test("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
|
|
87
338
|
const { seed, adapter } = harness;
|
|
88
339
|
if (!adapter.subscriptionRepository.countByPlanVersionId) {
|
|
@@ -126,7 +377,9 @@ function persistenceAdapterContract(options) {
|
|
|
126
377
|
return;
|
|
127
378
|
}
|
|
128
379
|
if (!adapter.promoCodeRedemptionRepository) {
|
|
129
|
-
t.skip(
|
|
380
|
+
t.skip(
|
|
381
|
+
"adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)"
|
|
382
|
+
);
|
|
130
383
|
return;
|
|
131
384
|
}
|
|
132
385
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
@@ -294,14 +547,24 @@ function persistenceAdapterContract(options) {
|
|
|
294
547
|
return;
|
|
295
548
|
}
|
|
296
549
|
await adapter.audit.write({
|
|
297
|
-
actor: {
|
|
550
|
+
actor: {
|
|
551
|
+
userId: "admin-1",
|
|
552
|
+
email: "ops@example.com",
|
|
553
|
+
source: "cli",
|
|
554
|
+
context: "host1"
|
|
555
|
+
},
|
|
298
556
|
entity: "Tenant",
|
|
299
557
|
entityId: "tenant-a",
|
|
300
558
|
action: "TENANT_SUSPEND",
|
|
301
559
|
changes: { reason: "test" }
|
|
302
560
|
});
|
|
303
561
|
await adapter.audit.write({
|
|
304
|
-
actor: {
|
|
562
|
+
actor: {
|
|
563
|
+
userId: "admin-2",
|
|
564
|
+
email: "web@example.com",
|
|
565
|
+
source: "web",
|
|
566
|
+
context: "sess9"
|
|
567
|
+
},
|
|
305
568
|
entity: "PromoCode",
|
|
306
569
|
entityId: "promo-1",
|
|
307
570
|
action: "PROMO_CODE_CREATE"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saasicat/persistence-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dist"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@saasicat/types": "^0.
|
|
25
|
+
"@saasicat/types": "^0.7.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^25.6.0",
|