@saasicat/persistence-testing 0.6.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 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, // optional slices activate more scenarios
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("adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)");
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: { userId: "admin-1", email: "ops@example.com", source: "cli", context: "host1" },
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: { userId: "admin-2", email: "web@example.com", source: "web", context: "sess9" },
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("adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)");
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: { userId: "admin-1", email: "ops@example.com", source: "cli", context: "host1" },
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: { userId: "admin-2", email: "web@example.com", source: "web", context: "sess9" },
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.6.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.6.0"
25
+ "@saasicat/types": "^0.7.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^25.6.0",