@saasicat/persistence-testing 1.0.0-rc.0 → 1.0.0-rc.10
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 +23 -1
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +980 -17
- package/dist/index.d.cts +47 -4
- package/dist/index.d.ts +47 -4
- package/dist/index.js +980 -17
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -149,13 +149,19 @@ function persistenceAdapterContract(options) {
|
|
|
149
149
|
plan: "STARTER",
|
|
150
150
|
planVersionId: oldVersion.planVersionId
|
|
151
151
|
});
|
|
152
|
-
await adapter.tenantSubscriptionWrite.changePlanImmediate(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
const change = await adapter.tenantSubscriptionWrite.changePlanImmediate(
|
|
153
|
+
"tenant-plan-change",
|
|
154
|
+
{
|
|
155
|
+
planId: "PRO",
|
|
156
|
+
cycle: "YEARLY",
|
|
157
|
+
periodStart: null,
|
|
158
|
+
periodEnd: null,
|
|
159
|
+
nextStatus: null,
|
|
160
|
+
// The row has no cancellation, so this claims it.
|
|
161
|
+
expectedCanceledAt: null
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
import_strict.default.equal(change.claimed, true, "the plan write did not claim the row");
|
|
159
165
|
const changed = await adapter.subscriptionRepository.findByTenantId("tenant-plan-change");
|
|
160
166
|
import_strict.default.ok(changed, "changed subscription expected");
|
|
161
167
|
import_strict.default.equal(changed.plan, "PRO");
|
|
@@ -206,7 +212,8 @@ function persistenceAdapterContract(options) {
|
|
|
206
212
|
cycle: "MONTHLY",
|
|
207
213
|
periodStart: null,
|
|
208
214
|
periodEnd: null,
|
|
209
|
-
nextStatus: null
|
|
215
|
+
nextStatus: null,
|
|
216
|
+
expectedCanceledAt: null
|
|
210
217
|
},
|
|
211
218
|
async (tx, callbackSubscriptionId) => {
|
|
212
219
|
import_strict.default.equal(callbackSubscriptionId, subscriptionId);
|
|
@@ -253,11 +260,10 @@ function persistenceAdapterContract(options) {
|
|
|
253
260
|
return;
|
|
254
261
|
}
|
|
255
262
|
const plan = await repository.create({
|
|
256
|
-
projectKey: options.projectKey,
|
|
257
263
|
planKey: "STANDARD",
|
|
258
264
|
label: "Standard"
|
|
259
265
|
});
|
|
260
|
-
import_strict.default.equal(plan.
|
|
266
|
+
import_strict.default.equal(plan.planKey, "STANDARD");
|
|
261
267
|
const firstDraft = await repository.createPlanVersionDraft({
|
|
262
268
|
planId: "STANDARD",
|
|
263
269
|
features: ["CORE"],
|
|
@@ -318,11 +324,10 @@ function persistenceAdapterContract(options) {
|
|
|
318
324
|
return;
|
|
319
325
|
}
|
|
320
326
|
const bundle = await repository.create({
|
|
321
|
-
projectKey: options.projectKey,
|
|
322
327
|
bundleKey: "REPORTING",
|
|
323
328
|
label: "Reporting"
|
|
324
329
|
});
|
|
325
|
-
import_strict.default.equal(bundle.
|
|
330
|
+
import_strict.default.equal(bundle.bundleKey, "REPORTING");
|
|
326
331
|
const firstDraft = await repository.createDraft({
|
|
327
332
|
bundleId: bundle.id,
|
|
328
333
|
features: ["REPORTS"],
|
|
@@ -370,6 +375,375 @@ function persistenceAdapterContract(options) {
|
|
|
370
375
|
second.id
|
|
371
376
|
);
|
|
372
377
|
});
|
|
378
|
+
(0, import_node_test.test)("a booking keeps the rhythm and the window it was made in", async (t) => {
|
|
379
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
380
|
+
const { seed } = harness;
|
|
381
|
+
if (!repository || !seed.createBundleVersion) {
|
|
382
|
+
t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
386
|
+
planKey: "PRO",
|
|
387
|
+
version: 1,
|
|
388
|
+
quotas: {},
|
|
389
|
+
features: ["CORE"],
|
|
390
|
+
published: true
|
|
391
|
+
});
|
|
392
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
393
|
+
tenantId: "tenant-bundle-period",
|
|
394
|
+
plan: "PRO",
|
|
395
|
+
planVersionId,
|
|
396
|
+
billingCycle: "YEARLY"
|
|
397
|
+
});
|
|
398
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
399
|
+
bundleKey: "ANALYTICS",
|
|
400
|
+
features: ["REPORTS"]
|
|
401
|
+
});
|
|
402
|
+
const booked = await repository.add({
|
|
403
|
+
subscriptionId,
|
|
404
|
+
bundleVersionId,
|
|
405
|
+
startedAt: /* @__PURE__ */ new Date("2026-02-21T00:00:00.000Z"),
|
|
406
|
+
minimumTermEndsAt: null,
|
|
407
|
+
billingCycle: "MONTHLY",
|
|
408
|
+
currentPeriodStart: /* @__PURE__ */ new Date("2026-02-21T00:00:00.000Z"),
|
|
409
|
+
currentPeriodEnd: /* @__PURE__ */ new Date("2026-02-28T00:00:00.000Z")
|
|
410
|
+
});
|
|
411
|
+
import_strict.default.equal(booked.billingCycle, "MONTHLY");
|
|
412
|
+
const [readBack] = await repository.listBySubscription(subscriptionId);
|
|
413
|
+
import_strict.default.ok(readBack, "the booking must be readable back");
|
|
414
|
+
import_strict.default.equal(readBack.billingCycle, "MONTHLY");
|
|
415
|
+
import_strict.default.equal(
|
|
416
|
+
readBack.currentPeriodEnd?.toISOString(),
|
|
417
|
+
"2026-02-28T00:00:00.000Z",
|
|
418
|
+
"the period end must survive the round trip"
|
|
419
|
+
);
|
|
420
|
+
import_strict.default.equal(readBack.currentPeriodStart?.toISOString(), "2026-02-21T00:00:00.000Z");
|
|
421
|
+
const legacy = await repository.add({
|
|
422
|
+
subscriptionId,
|
|
423
|
+
bundleVersionId,
|
|
424
|
+
startedAt: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
425
|
+
minimumTermEndsAt: null
|
|
426
|
+
});
|
|
427
|
+
const legacyReadBack = await repository.findById(legacy.id);
|
|
428
|
+
import_strict.default.ok(legacyReadBack, "the legacy booking must be readable back");
|
|
429
|
+
import_strict.default.equal(legacyReadBack.billingCycle, null);
|
|
430
|
+
import_strict.default.equal(legacyReadBack.currentPeriodStart, null);
|
|
431
|
+
import_strict.default.equal(legacyReadBack.currentPeriodEnd, null);
|
|
432
|
+
});
|
|
433
|
+
(0, import_node_test.test)("a second cancellation of one booking is refused, not applied", async (t) => {
|
|
434
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
435
|
+
const { seed } = harness;
|
|
436
|
+
if (!repository || !seed.createBundleVersion) {
|
|
437
|
+
t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
441
|
+
planKey: "PRO",
|
|
442
|
+
version: 1,
|
|
443
|
+
quotas: {},
|
|
444
|
+
features: ["CORE"],
|
|
445
|
+
published: true
|
|
446
|
+
});
|
|
447
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
448
|
+
tenantId: "tenant-double-cancel",
|
|
449
|
+
plan: "PRO",
|
|
450
|
+
planVersionId
|
|
451
|
+
});
|
|
452
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
453
|
+
bundleKey: "ANALYTICS",
|
|
454
|
+
features: ["REPORTS"]
|
|
455
|
+
});
|
|
456
|
+
const booking = await repository.add({
|
|
457
|
+
subscriptionId,
|
|
458
|
+
bundleVersionId,
|
|
459
|
+
startedAt: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
460
|
+
minimumTermEndsAt: null
|
|
461
|
+
});
|
|
462
|
+
const first = await repository.cancel(booking.id, {
|
|
463
|
+
canceledAt: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
464
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z")
|
|
465
|
+
});
|
|
466
|
+
import_strict.default.equal(first.canceledEffectiveAt?.toISOString(), "2026-04-01T00:00:00.000Z");
|
|
467
|
+
await import_strict.default.rejects(
|
|
468
|
+
() => repository.cancel(booking.id, {
|
|
469
|
+
canceledAt: /* @__PURE__ */ new Date("2026-03-02T00:00:00.000Z"),
|
|
470
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-09-01T00:00:00.000Z")
|
|
471
|
+
}),
|
|
472
|
+
"a second cancellation must be refused"
|
|
473
|
+
);
|
|
474
|
+
const readBack = await repository.findById(booking.id);
|
|
475
|
+
import_strict.default.equal(
|
|
476
|
+
readBack?.canceledEffectiveAt?.toISOString(),
|
|
477
|
+
"2026-04-01T00:00:00.000Z",
|
|
478
|
+
"the first cancellation must still stand"
|
|
479
|
+
);
|
|
480
|
+
await repository.reactivate(booking.id);
|
|
481
|
+
const again = await repository.cancel(booking.id, {
|
|
482
|
+
canceledAt: /* @__PURE__ */ new Date("2026-03-02T00:00:00.000Z"),
|
|
483
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-09-01T00:00:00.000Z")
|
|
484
|
+
});
|
|
485
|
+
import_strict.default.equal(again.canceledEffectiveAt?.toISOString(), "2026-09-01T00:00:00.000Z");
|
|
486
|
+
});
|
|
487
|
+
(0, import_node_test.test)("a subscription's bookings come back newest first", async (t) => {
|
|
488
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
489
|
+
const { seed } = harness;
|
|
490
|
+
if (!repository || !seed.createBundleVersion) {
|
|
491
|
+
t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
495
|
+
planKey: "PRO",
|
|
496
|
+
version: 1,
|
|
497
|
+
quotas: {},
|
|
498
|
+
features: ["CORE"],
|
|
499
|
+
published: true
|
|
500
|
+
});
|
|
501
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
502
|
+
tenantId: "tenant-booking-order",
|
|
503
|
+
plan: "PRO",
|
|
504
|
+
planVersionId
|
|
505
|
+
});
|
|
506
|
+
for (const [key, startedAt] of [
|
|
507
|
+
["OLDEST", "2026-01-01T00:00:00.000Z"],
|
|
508
|
+
["MIDDLE", "2026-02-01T00:00:00.000Z"],
|
|
509
|
+
["NEWEST", "2026-03-01T00:00:00.000Z"]
|
|
510
|
+
]) {
|
|
511
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
512
|
+
bundleKey: key,
|
|
513
|
+
features: ["REPORTS"]
|
|
514
|
+
});
|
|
515
|
+
await repository.add({
|
|
516
|
+
subscriptionId,
|
|
517
|
+
bundleVersionId,
|
|
518
|
+
startedAt: new Date(startedAt),
|
|
519
|
+
minimumTermEndsAt: null
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const listed = await repository.listBySubscription(subscriptionId);
|
|
523
|
+
import_strict.default.deepEqual(
|
|
524
|
+
listed.map((row) => row.startedAt.toISOString()),
|
|
525
|
+
[
|
|
526
|
+
"2026-03-01T00:00:00.000Z",
|
|
527
|
+
"2026-02-01T00:00:00.000Z",
|
|
528
|
+
"2026-01-01T00:00:00.000Z"
|
|
529
|
+
]
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
(0, import_node_test.test)("a booking with no request date is active, whatever its effective date says", async (t) => {
|
|
533
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
534
|
+
const { seed } = harness;
|
|
535
|
+
if (!repository || !seed.createBundleVersion) {
|
|
536
|
+
t.skip("adapter provides no SubscriptionBundleRepository or bundle catalog");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
540
|
+
planKey: "PRO",
|
|
541
|
+
version: 1,
|
|
542
|
+
quotas: {},
|
|
543
|
+
features: ["CORE"],
|
|
544
|
+
published: true
|
|
545
|
+
});
|
|
546
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
547
|
+
tenantId: "tenant-half-cancelled",
|
|
548
|
+
plan: "PRO",
|
|
549
|
+
planVersionId
|
|
550
|
+
});
|
|
551
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
552
|
+
bundleKey: "ANALYTICS",
|
|
553
|
+
features: ["REPORTS"]
|
|
554
|
+
});
|
|
555
|
+
const booking = await repository.add({
|
|
556
|
+
subscriptionId,
|
|
557
|
+
bundleVersionId,
|
|
558
|
+
startedAt: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
559
|
+
minimumTermEndsAt: null
|
|
560
|
+
});
|
|
561
|
+
await repository.cancel(booking.id, {
|
|
562
|
+
canceledAt: /* @__PURE__ */ new Date("2026-02-01T00:00:00.000Z"),
|
|
563
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z")
|
|
564
|
+
});
|
|
565
|
+
const clearRequestDate = harness.seed.clearBookingRequestDate;
|
|
566
|
+
if (!clearRequestDate) {
|
|
567
|
+
t.skip("adapter harness cannot write the half-cancelled shape");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
await clearRequestDate(booking.id);
|
|
571
|
+
const active = await repository.listActiveBySubscription(
|
|
572
|
+
subscriptionId,
|
|
573
|
+
/* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z")
|
|
574
|
+
);
|
|
575
|
+
import_strict.default.equal(active.length, 1, "no request date means nobody asked to cancel it");
|
|
576
|
+
import_strict.default.equal(
|
|
577
|
+
await repository.countActiveByBundleVersionId(
|
|
578
|
+
bundleVersionId,
|
|
579
|
+
/* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z")
|
|
580
|
+
),
|
|
581
|
+
1
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
(0, import_node_test.test)("discarding a draft cannot remove a version published meanwhile", async (t) => {
|
|
585
|
+
const catalog = harness.adapter.bundleRepository;
|
|
586
|
+
const discardDraft = catalog?.deleteDraft?.bind(catalog);
|
|
587
|
+
if (!catalog || !discardDraft) {
|
|
588
|
+
t.skip("adapter provides no BundleRepository");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const bundle = await catalog.create({
|
|
592
|
+
bundleKey: "RACE",
|
|
593
|
+
label: "Race"
|
|
594
|
+
});
|
|
595
|
+
const draft = await catalog.createDraft({
|
|
596
|
+
bundleId: bundle.id,
|
|
597
|
+
features: ["REPORTS"],
|
|
598
|
+
quotas: {}
|
|
599
|
+
});
|
|
600
|
+
await catalog.publishDraft(draft.id, {
|
|
601
|
+
publishedByUserId: null,
|
|
602
|
+
publishedChanges: [],
|
|
603
|
+
nonRegressive: true,
|
|
604
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
605
|
+
validUntil: null
|
|
606
|
+
});
|
|
607
|
+
await import_strict.default.rejects(
|
|
608
|
+
() => discardDraft(draft.id),
|
|
609
|
+
"a published version must not be discardable"
|
|
610
|
+
);
|
|
611
|
+
import_strict.default.ok(
|
|
612
|
+
await catalog.findVersionById(draft.id),
|
|
613
|
+
"and it must still be there afterwards"
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
(0, import_node_test.test)("publishing one draft twice claims it once, and the windows stay adjacent", async (t) => {
|
|
617
|
+
const catalog = harness.adapter.bundleRepository;
|
|
618
|
+
const publish = catalog?.publishDraft?.bind(catalog);
|
|
619
|
+
if (!catalog || !publish) {
|
|
620
|
+
t.skip("adapter provides no BundleRepository");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const bundle = await catalog.create({
|
|
624
|
+
bundleKey: "CLAIM",
|
|
625
|
+
label: "Claim"
|
|
626
|
+
});
|
|
627
|
+
const first = await catalog.createDraft({
|
|
628
|
+
bundleId: bundle.id,
|
|
629
|
+
features: ["A"],
|
|
630
|
+
quotas: {}
|
|
631
|
+
});
|
|
632
|
+
await publish(first.id, {
|
|
633
|
+
publishedByUserId: null,
|
|
634
|
+
publishedChanges: [],
|
|
635
|
+
nonRegressive: true,
|
|
636
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
637
|
+
validUntil: null
|
|
638
|
+
});
|
|
639
|
+
const second = await catalog.createDraft({
|
|
640
|
+
bundleId: bundle.id,
|
|
641
|
+
baseVersionId: first.id,
|
|
642
|
+
features: ["A", "B"],
|
|
643
|
+
quotas: {}
|
|
644
|
+
});
|
|
645
|
+
const publishedAt = /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z");
|
|
646
|
+
await publish(second.id, {
|
|
647
|
+
publishedByUserId: null,
|
|
648
|
+
publishedChanges: [],
|
|
649
|
+
nonRegressive: true,
|
|
650
|
+
validFrom: publishedAt,
|
|
651
|
+
validUntil: null
|
|
652
|
+
});
|
|
653
|
+
await import_strict.default.rejects(
|
|
654
|
+
() => publish(second.id, {
|
|
655
|
+
publishedByUserId: null,
|
|
656
|
+
publishedChanges: [],
|
|
657
|
+
nonRegressive: true,
|
|
658
|
+
validFrom: /* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z"),
|
|
659
|
+
validUntil: null
|
|
660
|
+
}),
|
|
661
|
+
"a version that is already published must not be published again"
|
|
662
|
+
);
|
|
663
|
+
const successor = await catalog.findVersionById(second.id);
|
|
664
|
+
const predecessor = await catalog.findVersionById(first.id);
|
|
665
|
+
import_strict.default.equal(
|
|
666
|
+
successor?.validFrom && new Date(successor.validFrom).toISOString(),
|
|
667
|
+
publishedAt.toISOString(),
|
|
668
|
+
"the winning date must still stand"
|
|
669
|
+
);
|
|
670
|
+
import_strict.default.ok(predecessor?.supersededAt, "the predecessor must be superseded");
|
|
671
|
+
if (predecessor?.validUntil) {
|
|
672
|
+
const closesAt = new Date(predecessor.validUntil);
|
|
673
|
+
import_strict.default.equal(
|
|
674
|
+
closesAt.toISOString().slice(0, 10),
|
|
675
|
+
"2026-02-28",
|
|
676
|
+
"the predecessor must close the day before its successor opens"
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
(0, import_node_test.test)("a plan key names one plan for the whole installation", async (t) => {
|
|
681
|
+
const repository = harness.adapter.planRepository;
|
|
682
|
+
if (!repository) {
|
|
683
|
+
t.skip("adapter provides no PlanRepository");
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
await repository.create({ planKey: "DOUBLE", label: "First" });
|
|
687
|
+
await import_strict.default.rejects(
|
|
688
|
+
() => repository.create({ planKey: "DOUBLE", label: "Second" }),
|
|
689
|
+
"a plan key is taken once"
|
|
690
|
+
);
|
|
691
|
+
});
|
|
692
|
+
(0, import_node_test.test)("a bundle key names one bundle for the whole installation", async (t) => {
|
|
693
|
+
const catalog = harness.adapter.bundleRepository;
|
|
694
|
+
if (!catalog) {
|
|
695
|
+
t.skip("adapter provides no BundleRepository");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
await catalog.create({ bundleKey: "DOUBLE", label: "First" });
|
|
699
|
+
await import_strict.default.rejects(
|
|
700
|
+
() => catalog.create({ bundleKey: "DOUBLE", label: "Second" }),
|
|
701
|
+
"a bundle key is taken once"
|
|
702
|
+
);
|
|
703
|
+
});
|
|
704
|
+
(0, import_node_test.test)("a retired plan still occupies its key", async (t) => {
|
|
705
|
+
const repository = harness.adapter.planRepository;
|
|
706
|
+
const retire = repository?.softDelete?.bind(repository);
|
|
707
|
+
const byKey = repository?.findByKey?.bind(repository);
|
|
708
|
+
if (!repository || !retire || !byKey) {
|
|
709
|
+
t.skip("adapter provides no PlanRepository");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const plan = await repository.create({ planKey: "RETIRED_PLAN", label: "Retired" });
|
|
713
|
+
await retire(plan.id);
|
|
714
|
+
const stillThere = await byKey("RETIRED_PLAN");
|
|
715
|
+
import_strict.default.equal(stillThere?.id, plan.id, "the key is not free again");
|
|
716
|
+
import_strict.default.ok(stillThere?.deletedAt, "and the row says it is retired");
|
|
717
|
+
import_strict.default.equal(
|
|
718
|
+
(await repository.list({})).some((row) => row.id === plan.id),
|
|
719
|
+
false,
|
|
720
|
+
"a retired plan is not in the catalogue an operator browses"
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
(0, import_node_test.test)("a retired bundle still occupies its key", async (t) => {
|
|
724
|
+
const catalog = harness.adapter.bundleRepository;
|
|
725
|
+
const retire = catalog?.softDelete?.bind(catalog);
|
|
726
|
+
const byKey = catalog?.findByKey?.bind(catalog);
|
|
727
|
+
if (!catalog || !retire || !byKey) {
|
|
728
|
+
t.skip("adapter provides no BundleRepository");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const bundle = await catalog.create({
|
|
732
|
+
bundleKey: "RETIRED_KEY",
|
|
733
|
+
label: "Retired"
|
|
734
|
+
});
|
|
735
|
+
import_strict.default.equal((await byKey("RETIRED_KEY"))?.id, bundle.id);
|
|
736
|
+
await retire(bundle.id);
|
|
737
|
+
const stillThere = await byKey("RETIRED_KEY");
|
|
738
|
+
import_strict.default.equal(stillThere?.id, bundle.id, "the key is not free again");
|
|
739
|
+
import_strict.default.ok(stillThere?.deletedAt, "and the row says it is retired");
|
|
740
|
+
const listed = await catalog.list({});
|
|
741
|
+
import_strict.default.equal(
|
|
742
|
+
listed.some((row) => row.id === bundle.id),
|
|
743
|
+
false,
|
|
744
|
+
"a retired bundle is not in the catalogue an operator browses"
|
|
745
|
+
);
|
|
746
|
+
});
|
|
373
747
|
(0, import_node_test.test)("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
|
|
374
748
|
const { seed, adapter } = harness;
|
|
375
749
|
if (!adapter.subscriptionRepository.countByPlanVersionId) {
|
|
@@ -740,13 +1114,602 @@ function persistenceAdapterContract(options) {
|
|
|
740
1114
|
import_strict.default.equal(seen?.id, subscriptionId);
|
|
741
1115
|
import_strict.default.equal(seen?.tenantId, "tenant-a");
|
|
742
1116
|
});
|
|
743
|
-
(0, import_node_test.test)("
|
|
744
|
-
|
|
745
|
-
|
|
1117
|
+
(0, import_node_test.test)("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
|
|
1118
|
+
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1119
|
+
if (!contracts) {
|
|
1120
|
+
t.skip("adapter provides no SubscriptionContractRepository");
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const tenantId = "tenant-contract-lifecycle";
|
|
1124
|
+
const signedAt = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
|
|
1125
|
+
const created = await contracts.create({
|
|
1126
|
+
tenantId,
|
|
1127
|
+
effectiveFrom: signedAt,
|
|
1128
|
+
priceSnapshot: {
|
|
1129
|
+
currency: "EUR",
|
|
1130
|
+
billingCycle: "monthly",
|
|
1131
|
+
subtotalNet: 29.9,
|
|
1132
|
+
discountNet: 0,
|
|
1133
|
+
totalNet: 29.9,
|
|
1134
|
+
vatRate: 19,
|
|
1135
|
+
totalGross: 35.58
|
|
1136
|
+
},
|
|
1137
|
+
entitlementSnapshot: {
|
|
1138
|
+
plan: "STANDARD",
|
|
1139
|
+
features: ["CORE"],
|
|
1140
|
+
quotas: { users: 5 }
|
|
1141
|
+
},
|
|
1142
|
+
originalBundleVersionIds: ["bundle-version-1"],
|
|
1143
|
+
termsSnapshot: { noticePeriodDays: 30 },
|
|
1144
|
+
lineItems: [
|
|
1145
|
+
{
|
|
1146
|
+
kind: "plan",
|
|
1147
|
+
sourceKey: "STANDARD",
|
|
1148
|
+
sourceVersionId: "plan-version-1",
|
|
1149
|
+
titleSnapshot: "Standard",
|
|
1150
|
+
descriptionSnapshot: "The plan as it was signed",
|
|
1151
|
+
quantity: 1,
|
|
1152
|
+
unit: null,
|
|
1153
|
+
priceNet: 19.9,
|
|
1154
|
+
priceGross: 23.68,
|
|
1155
|
+
billingCycle: "monthly",
|
|
1156
|
+
currency: "EUR",
|
|
1157
|
+
taxRate: 19,
|
|
1158
|
+
taxAmount: 3.78,
|
|
1159
|
+
minimumTermUntil: /* @__PURE__ */ new Date("2027-01-01T00:00:00.000Z"),
|
|
1160
|
+
featuresSnapshot: ["CORE"],
|
|
1161
|
+
quotaEffectsSnapshot: { users: 5 },
|
|
1162
|
+
metadata: { origin: "onboarding" }
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
kind: "bundle",
|
|
1166
|
+
sourceKey: "EXTRA-SEATS",
|
|
1167
|
+
sourceVersionId: "bundle-version-1",
|
|
1168
|
+
titleSnapshot: "Extra seats",
|
|
1169
|
+
descriptionSnapshot: null,
|
|
1170
|
+
quantity: 1,
|
|
1171
|
+
unit: "seat",
|
|
1172
|
+
priceNet: 10,
|
|
1173
|
+
priceGross: 11.9,
|
|
1174
|
+
billingCycle: "monthly",
|
|
1175
|
+
currency: "EUR",
|
|
1176
|
+
taxRate: 19,
|
|
1177
|
+
taxAmount: 1.9,
|
|
1178
|
+
minimumTermUntil: null,
|
|
1179
|
+
featuresSnapshot: [],
|
|
1180
|
+
quotaEffectsSnapshot: { users: 5 },
|
|
1181
|
+
metadata: null
|
|
1182
|
+
}
|
|
1183
|
+
]
|
|
1184
|
+
});
|
|
1185
|
+
import_strict.default.equal(created.status, "active");
|
|
1186
|
+
import_strict.default.equal(created.tenantId, tenantId);
|
|
1187
|
+
import_strict.default.equal(created.lineItems.length, 2);
|
|
1188
|
+
import_strict.default.deepEqual(created.originalBundleVersionIds, ["bundle-version-1"]);
|
|
1189
|
+
import_strict.default.deepEqual(created.termsSnapshot, { noticePeriodDays: 30 });
|
|
1190
|
+
const planLine = created.lineItems.find((item) => item.kind === "plan");
|
|
1191
|
+
import_strict.default.ok(planLine, "plan line expected");
|
|
1192
|
+
import_strict.default.equal(planLine.priceNet, 19.9, "money must survive the round trip unrounded");
|
|
1193
|
+
import_strict.default.equal(planLine.priceGross, 23.68);
|
|
1194
|
+
import_strict.default.equal(planLine.billingCycle, "monthly");
|
|
1195
|
+
import_strict.default.deepEqual(planLine.quotaEffectsSnapshot, { users: 5 });
|
|
1196
|
+
import_strict.default.equal(planLine.descriptionSnapshot, "The plan as it was signed");
|
|
1197
|
+
import_strict.default.equal(
|
|
1198
|
+
planLine.minimumTermUntil?.getTime(),
|
|
1199
|
+
(/* @__PURE__ */ new Date("2027-01-01T00:00:00.000Z")).getTime(),
|
|
1200
|
+
"the commitment is part of what was agreed"
|
|
1201
|
+
);
|
|
1202
|
+
import_strict.default.deepEqual(planLine.metadata, { origin: "onboarding" });
|
|
1203
|
+
import_strict.default.equal(planLine.currency, "EUR", "the line says what it was booked in");
|
|
1204
|
+
import_strict.default.equal(planLine.taxRate, 19, "the rate is a recorded fact, not the ratio");
|
|
1205
|
+
import_strict.default.equal(planLine.taxAmount, 3.78);
|
|
1206
|
+
import_strict.default.equal(
|
|
1207
|
+
Math.round((planLine.priceNet + planLine.taxAmount) * 100) / 100,
|
|
1208
|
+
planLine.priceGross,
|
|
1209
|
+
"the tax closes the gap between net and gross"
|
|
1210
|
+
);
|
|
1211
|
+
const bundleLine = created.lineItems.find((item) => item.kind === "bundle");
|
|
1212
|
+
import_strict.default.ok(bundleLine, "bundle line expected");
|
|
1213
|
+
import_strict.default.equal(bundleLine.descriptionSnapshot, null);
|
|
1214
|
+
import_strict.default.equal(bundleLine.unit, "seat");
|
|
1215
|
+
import_strict.default.equal(bundleLine.minimumTermUntil, null);
|
|
1216
|
+
import_strict.default.equal(bundleLine.metadata, null);
|
|
1217
|
+
const readBack = await contracts.findById(created.id);
|
|
1218
|
+
import_strict.default.ok(readBack, "contract expected by id");
|
|
1219
|
+
import_strict.default.equal(
|
|
1220
|
+
readBack.lineItems.length,
|
|
1221
|
+
2,
|
|
1222
|
+
"lines belong to the contract, not the call"
|
|
1223
|
+
);
|
|
1224
|
+
import_strict.default.equal(
|
|
1225
|
+
(await contracts.findActiveByTenantId(
|
|
1226
|
+
tenantId,
|
|
1227
|
+
/* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z")
|
|
1228
|
+
))?.id,
|
|
1229
|
+
created.id
|
|
1230
|
+
);
|
|
1231
|
+
import_strict.default.equal(
|
|
1232
|
+
await contracts.findActiveByTenantId(
|
|
1233
|
+
tenantId,
|
|
1234
|
+
/* @__PURE__ */ new Date("2025-12-31T23:59:59.999Z")
|
|
1235
|
+
),
|
|
1236
|
+
null,
|
|
1237
|
+
"a contract is not active before it starts"
|
|
1238
|
+
);
|
|
1239
|
+
const endsAt = /* @__PURE__ */ new Date("2026-07-01T00:00:00.000Z");
|
|
1240
|
+
const terminated = await contracts.terminate(created.id, {
|
|
1241
|
+
effectiveUntil: endsAt,
|
|
1242
|
+
status: null
|
|
1243
|
+
});
|
|
1244
|
+
import_strict.default.equal(
|
|
1245
|
+
terminated.status,
|
|
1246
|
+
"active",
|
|
1247
|
+
"a null status leaves the contract in the state it had"
|
|
1248
|
+
);
|
|
1249
|
+
import_strict.default.equal(terminated.effectiveUntil?.getTime(), endsAt.getTime());
|
|
1250
|
+
import_strict.default.equal(terminated.lineItems.length, 2, "ending a contract keeps its lines");
|
|
1251
|
+
import_strict.default.equal(
|
|
1252
|
+
terminated.priceSnapshot.totalNet,
|
|
1253
|
+
created.priceSnapshot.totalNet,
|
|
1254
|
+
"ending a contract does not restate its price"
|
|
1255
|
+
);
|
|
1256
|
+
const afterEnd = await contracts.findById(created.id);
|
|
1257
|
+
import_strict.default.ok(afterEnd, "a terminated contract is still readable");
|
|
1258
|
+
import_strict.default.equal(afterEnd.lineItems.length, 2);
|
|
1259
|
+
import_strict.default.equal(
|
|
1260
|
+
(await contracts.findActiveByTenantId(
|
|
1261
|
+
tenantId,
|
|
1262
|
+
/* @__PURE__ */ new Date("2026-06-30T00:00:00.000Z")
|
|
1263
|
+
))?.id,
|
|
1264
|
+
created.id,
|
|
1265
|
+
"active up to the moment it ends"
|
|
1266
|
+
);
|
|
1267
|
+
import_strict.default.equal(
|
|
1268
|
+
await contracts.findActiveByTenantId(tenantId, endsAt),
|
|
1269
|
+
null,
|
|
1270
|
+
"and not at that moment"
|
|
1271
|
+
);
|
|
1272
|
+
});
|
|
1273
|
+
(0, import_node_test.test)("a successor takes over without erasing the contract it replaces", async (t) => {
|
|
1274
|
+
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1275
|
+
if (!contracts) {
|
|
1276
|
+
t.skip("adapter provides no SubscriptionContractRepository");
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const tenantId = "tenant-contract-succession";
|
|
1280
|
+
const handover = /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z");
|
|
1281
|
+
const lineAt = (priceNet, priceGross) => ({
|
|
1282
|
+
kind: "plan",
|
|
1283
|
+
sourceKey: "STANDARD",
|
|
1284
|
+
sourceVersionId: null,
|
|
1285
|
+
titleSnapshot: "Standard",
|
|
1286
|
+
descriptionSnapshot: null,
|
|
1287
|
+
quantity: 1,
|
|
1288
|
+
unit: null,
|
|
1289
|
+
priceNet,
|
|
1290
|
+
priceGross,
|
|
1291
|
+
billingCycle: "monthly",
|
|
1292
|
+
currency: "EUR",
|
|
1293
|
+
taxRate: 19,
|
|
1294
|
+
taxAmount: Math.round((priceGross - priceNet) * 100) / 100,
|
|
1295
|
+
minimumTermUntil: null,
|
|
1296
|
+
featuresSnapshot: [],
|
|
1297
|
+
quotaEffectsSnapshot: {},
|
|
1298
|
+
metadata: null
|
|
1299
|
+
});
|
|
1300
|
+
const priceAt = (net, gross) => ({
|
|
1301
|
+
currency: "EUR",
|
|
1302
|
+
billingCycle: "monthly",
|
|
1303
|
+
subtotalNet: net,
|
|
1304
|
+
discountNet: 0,
|
|
1305
|
+
totalNet: net,
|
|
1306
|
+
vatRate: 19,
|
|
1307
|
+
totalGross: gross
|
|
1308
|
+
});
|
|
1309
|
+
const first = await contracts.create({
|
|
1310
|
+
tenantId,
|
|
1311
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1312
|
+
priceSnapshot: priceAt(19.9, 23.68),
|
|
1313
|
+
lineItems: [lineAt(19.9, 23.68)]
|
|
1314
|
+
});
|
|
1315
|
+
import_strict.default.equal(
|
|
1316
|
+
(await contracts.findActiveByTenantId(
|
|
1317
|
+
tenantId,
|
|
1318
|
+
/* @__PURE__ */ new Date("2026-03-31T00:00:00.000Z")
|
|
1319
|
+
))?.id,
|
|
1320
|
+
first.id
|
|
1321
|
+
);
|
|
1322
|
+
await contracts.terminate(first.id, {
|
|
1323
|
+
effectiveUntil: handover,
|
|
1324
|
+
status: "superseded"
|
|
1325
|
+
});
|
|
1326
|
+
const second = await contracts.create({
|
|
1327
|
+
tenantId,
|
|
1328
|
+
effectiveFrom: handover,
|
|
1329
|
+
priceSnapshot: priceAt(24.9, 29.63),
|
|
1330
|
+
lineItems: [lineAt(24.9, 29.63)]
|
|
1331
|
+
});
|
|
1332
|
+
import_strict.default.equal(
|
|
1333
|
+
(await contracts.findActiveByTenantId(tenantId, handover))?.id,
|
|
1334
|
+
second.id,
|
|
1335
|
+
"the successor takes over at the moment the predecessor ends"
|
|
1336
|
+
);
|
|
1337
|
+
import_strict.default.equal(
|
|
1338
|
+
await contracts.findActiveByTenantId(
|
|
1339
|
+
tenantId,
|
|
1340
|
+
/* @__PURE__ */ new Date("2026-03-31T00:00:00.000Z")
|
|
1341
|
+
),
|
|
1342
|
+
null,
|
|
1343
|
+
"a superseded contract is not live at any asOf"
|
|
1344
|
+
);
|
|
1345
|
+
const superseded = await contracts.findById(first.id);
|
|
1346
|
+
import_strict.default.equal(superseded?.status, "superseded");
|
|
1347
|
+
import_strict.default.equal(
|
|
1348
|
+
superseded?.priceSnapshot.totalNet,
|
|
1349
|
+
19.9,
|
|
1350
|
+
"the replaced contract keeps the price it was signed at"
|
|
1351
|
+
);
|
|
1352
|
+
const history = await contracts.list({ tenantId });
|
|
1353
|
+
import_strict.default.equal(history.length, 2, "both contracts remain in the history");
|
|
1354
|
+
import_strict.default.deepEqual(
|
|
1355
|
+
history.map((contract) => contract.lineItems.map((item) => item.priceNet)),
|
|
1356
|
+
[[24.9], [19.9]]
|
|
1357
|
+
);
|
|
1358
|
+
import_strict.default.deepEqual(
|
|
1359
|
+
history.map((contract) => contract.id),
|
|
1360
|
+
[second.id, first.id],
|
|
1361
|
+
"newest first"
|
|
1362
|
+
);
|
|
1363
|
+
import_strict.default.deepEqual(
|
|
1364
|
+
(await contracts.list({ tenantId, asOf: /* @__PURE__ */ new Date("2026-03-31T00:00:00.000Z") })).map((contract) => contract.id),
|
|
1365
|
+
[first.id],
|
|
1366
|
+
"asOf narrows the history to what was in force then"
|
|
1367
|
+
);
|
|
1368
|
+
import_strict.default.deepEqual(
|
|
1369
|
+
(await contracts.list({ tenantId, status: "superseded" })).map(
|
|
1370
|
+
(contract) => contract.id
|
|
1371
|
+
),
|
|
1372
|
+
[first.id]
|
|
1373
|
+
);
|
|
1374
|
+
});
|
|
1375
|
+
(0, import_node_test.test)("a line keeps the currency and the tax it was booked with", async (t) => {
|
|
1376
|
+
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1377
|
+
if (!contracts) {
|
|
1378
|
+
t.skip("adapter provides no SubscriptionContractRepository");
|
|
746
1379
|
return;
|
|
747
1380
|
}
|
|
748
|
-
|
|
749
|
-
|
|
1381
|
+
const tenantId = "tenant-contract-money-facts";
|
|
1382
|
+
const created = await contracts.create({
|
|
1383
|
+
tenantId,
|
|
1384
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1385
|
+
priceSnapshot: {
|
|
1386
|
+
currency: "CHF",
|
|
1387
|
+
billingCycle: "monthly",
|
|
1388
|
+
subtotalNet: 100,
|
|
1389
|
+
discountNet: 10,
|
|
1390
|
+
totalNet: 90,
|
|
1391
|
+
vatRate: 8.1,
|
|
1392
|
+
totalGross: 97.29
|
|
1393
|
+
},
|
|
1394
|
+
lineItems: [
|
|
1395
|
+
{
|
|
1396
|
+
kind: "plan",
|
|
1397
|
+
sourceKey: "STANDARD",
|
|
1398
|
+
sourceVersionId: null,
|
|
1399
|
+
titleSnapshot: "Standard",
|
|
1400
|
+
descriptionSnapshot: null,
|
|
1401
|
+
quantity: 1,
|
|
1402
|
+
unit: null,
|
|
1403
|
+
priceNet: 100,
|
|
1404
|
+
priceGross: 108.1,
|
|
1405
|
+
billingCycle: "monthly",
|
|
1406
|
+
// Not the installation this suite's other contracts
|
|
1407
|
+
// run under, and not a whole number of per cent —
|
|
1408
|
+
// a rate stored as an integer, or a currency taken
|
|
1409
|
+
// from a default, passes every other case here.
|
|
1410
|
+
currency: "CHF",
|
|
1411
|
+
taxRate: 8.1,
|
|
1412
|
+
taxAmount: 8.1,
|
|
1413
|
+
minimumTermUntil: null,
|
|
1414
|
+
featuresSnapshot: [],
|
|
1415
|
+
quotaEffectsSnapshot: {},
|
|
1416
|
+
metadata: null
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
kind: "discount",
|
|
1420
|
+
sourceKey: "WELCOME10",
|
|
1421
|
+
sourceVersionId: null,
|
|
1422
|
+
titleSnapshot: "Welcome discount",
|
|
1423
|
+
descriptionSnapshot: null,
|
|
1424
|
+
quantity: 1,
|
|
1425
|
+
unit: null,
|
|
1426
|
+
priceNet: -10,
|
|
1427
|
+
priceGross: -10.81,
|
|
1428
|
+
billingCycle: "monthly",
|
|
1429
|
+
currency: "CHF",
|
|
1430
|
+
taxRate: 8.1,
|
|
1431
|
+
taxAmount: -0.81,
|
|
1432
|
+
minimumTermUntil: null,
|
|
1433
|
+
featuresSnapshot: [],
|
|
1434
|
+
quotaEffectsSnapshot: {},
|
|
1435
|
+
metadata: null
|
|
1436
|
+
}
|
|
1437
|
+
]
|
|
1438
|
+
});
|
|
1439
|
+
const read = await contracts.findById(created.id);
|
|
1440
|
+
const plan = read?.lineItems.find((item) => item.kind === "plan");
|
|
1441
|
+
const discount = read?.lineItems.find((item) => item.kind === "discount");
|
|
1442
|
+
import_strict.default.ok(plan && discount, "both lines expected");
|
|
1443
|
+
import_strict.default.equal(plan.currency, "CHF");
|
|
1444
|
+
import_strict.default.equal(plan.taxRate, 8.1, "a fractional rate survives the column");
|
|
1445
|
+
import_strict.default.equal(plan.taxAmount, 8.1);
|
|
1446
|
+
import_strict.default.equal(discount.currency, "CHF");
|
|
1447
|
+
import_strict.default.equal(discount.taxRate, 8.1);
|
|
1448
|
+
import_strict.default.equal(discount.taxAmount, -0.81);
|
|
1449
|
+
for (const line of [plan, discount]) {
|
|
1450
|
+
import_strict.default.equal(
|
|
1451
|
+
Math.round((line.priceNet + line.taxAmount) * 100) / 100,
|
|
1452
|
+
line.priceGross,
|
|
1453
|
+
"net plus tax is gross, on every line"
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
const SETTINGS = {
|
|
1458
|
+
app: { name: "Demo" },
|
|
1459
|
+
currency: "EUR",
|
|
1460
|
+
vatRate: 19,
|
|
1461
|
+
tenantBilling: {
|
|
1462
|
+
cancellationNoticeDays: { monthly: 14, yearly: 90 },
|
|
1463
|
+
selfServiceBlockedPlans: { asTarget: ["ENTERPRISE"], asSource: [] }
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
const SOURCE = "/srv/app/config/saas.yaml";
|
|
1467
|
+
const FIRST_START = /* @__PURE__ */ new Date("2026-09-01T06:30:00.000Z");
|
|
1468
|
+
const SECOND_START = /* @__PURE__ */ new Date("2026-09-02T06:30:00.000Z");
|
|
1469
|
+
const applied = (fingerprint, appliedAt, settings = SETTINGS) => ({ fingerprint, settings, source: SOURCE, appliedAt });
|
|
1470
|
+
const changeTo = (current, noticedAt, previous = SETTINGS) => ({ noticedAt, source: SOURCE, previous, current });
|
|
1471
|
+
const TWENTY = { ...SETTINGS, vatRate: 20 };
|
|
1472
|
+
const TWENTY_ONE = { ...SETTINGS, vatRate: 21 };
|
|
1473
|
+
(0, import_node_test.test)("no record before the first boot that could write one", async (t) => {
|
|
1474
|
+
const port = harness.adapter.appliedSettings;
|
|
1475
|
+
if (!port) {
|
|
1476
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
import_strict.default.equal(await port.readApplied(), null);
|
|
1480
|
+
import_strict.default.deepEqual(await port.listChanges(), []);
|
|
1481
|
+
});
|
|
1482
|
+
(0, import_node_test.test)("the record comes back as it was written \u2014 values, source and moment", async (t) => {
|
|
1483
|
+
const port = harness.adapter.appliedSettings;
|
|
1484
|
+
if (!port) {
|
|
1485
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
import_strict.default.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
|
|
1489
|
+
const read = await port.readApplied();
|
|
1490
|
+
import_strict.default.ok(read, "a record expected");
|
|
1491
|
+
import_strict.default.equal(read.fingerprint, "sha256-a");
|
|
1492
|
+
import_strict.default.equal(read.source, SOURCE);
|
|
1493
|
+
import_strict.default.equal(read.appliedAt.toISOString(), FIRST_START.toISOString());
|
|
1494
|
+
import_strict.default.deepEqual(read.settings, SETTINGS);
|
|
1495
|
+
});
|
|
1496
|
+
(0, import_node_test.test)("writing again replaces the one row rather than adding a second", async (t) => {
|
|
1497
|
+
const port = harness.adapter.appliedSettings;
|
|
1498
|
+
if (!port) {
|
|
1499
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
1503
|
+
import_strict.default.equal(
|
|
1504
|
+
await port.writeApplied(applied("sha256-b", SECOND_START, TWENTY), "sha256-a"),
|
|
1505
|
+
true
|
|
1506
|
+
);
|
|
1507
|
+
const read = await port.readApplied();
|
|
1508
|
+
import_strict.default.equal(read?.fingerprint, "sha256-b");
|
|
1509
|
+
import_strict.default.equal(read?.appliedAt.toISOString(), SECOND_START.toISOString());
|
|
1510
|
+
import_strict.default.equal((read?.settings).vatRate, 20);
|
|
1511
|
+
});
|
|
1512
|
+
(0, import_node_test.test)("the first record is written once: a second writer that read none is refused", async (t) => {
|
|
1513
|
+
const port = harness.adapter.appliedSettings;
|
|
1514
|
+
if (!port) {
|
|
1515
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
import_strict.default.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
|
|
1519
|
+
import_strict.default.equal(await port.writeApplied(applied("sha256-b", SECOND_START), null), false);
|
|
1520
|
+
import_strict.default.equal((await port.readApplied())?.fingerprint, "sha256-a");
|
|
1521
|
+
});
|
|
1522
|
+
(0, import_node_test.test)("a write guarded on a fingerprint the row no longer carries is refused", async (t) => {
|
|
1523
|
+
const port = harness.adapter.appliedSettings;
|
|
1524
|
+
if (!port) {
|
|
1525
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
1529
|
+
import_strict.default.equal(
|
|
1530
|
+
await port.writeApplied(applied("sha256-b", SECOND_START), "sha256-a"),
|
|
1531
|
+
true
|
|
1532
|
+
);
|
|
1533
|
+
import_strict.default.equal(
|
|
1534
|
+
await port.writeApplied(applied("sha256-c", SECOND_START), "sha256-a"),
|
|
1535
|
+
false
|
|
1536
|
+
);
|
|
1537
|
+
const read = await port.readApplied();
|
|
1538
|
+
import_strict.default.equal(
|
|
1539
|
+
read?.fingerprint,
|
|
1540
|
+
"sha256-b",
|
|
1541
|
+
"the row stands as the first writer left it"
|
|
1542
|
+
);
|
|
1543
|
+
import_strict.default.equal(read?.appliedAt.toISOString(), SECOND_START.toISOString());
|
|
1544
|
+
});
|
|
1545
|
+
(0, import_node_test.test)("a change lands with the record it supersedes, and is listed newest first", async (t) => {
|
|
1546
|
+
const port = harness.adapter.appliedSettings;
|
|
1547
|
+
if (!port) {
|
|
1548
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
1552
|
+
const first = await port.recordChange(
|
|
1553
|
+
changeTo(TWENTY, FIRST_START),
|
|
1554
|
+
applied("sha256-b", FIRST_START, TWENTY),
|
|
1555
|
+
"sha256-a"
|
|
1556
|
+
);
|
|
1557
|
+
const second = await port.recordChange(
|
|
1558
|
+
changeTo(TWENTY_ONE, SECOND_START, TWENTY),
|
|
1559
|
+
applied("sha256-c", SECOND_START, TWENTY_ONE),
|
|
1560
|
+
"sha256-b"
|
|
1561
|
+
);
|
|
1562
|
+
import_strict.default.ok(first && second, "both guards held");
|
|
1563
|
+
import_strict.default.ok(first.id && second.id && first.id !== second.id, "two distinct ids");
|
|
1564
|
+
import_strict.default.equal(first.acknowledgedAt, null);
|
|
1565
|
+
import_strict.default.equal(first.acknowledgedBy, null);
|
|
1566
|
+
import_strict.default.equal(
|
|
1567
|
+
(await port.readApplied())?.fingerprint,
|
|
1568
|
+
"sha256-c",
|
|
1569
|
+
"the record moved with the change"
|
|
1570
|
+
);
|
|
1571
|
+
const listed = await port.listChanges();
|
|
1572
|
+
import_strict.default.deepEqual(
|
|
1573
|
+
listed.map((c) => c.id),
|
|
1574
|
+
[second.id, first.id]
|
|
1575
|
+
);
|
|
1576
|
+
import_strict.default.deepEqual(listed[1].previous, SETTINGS);
|
|
1577
|
+
import_strict.default.equal(listed[1].current.vatRate, 20);
|
|
1578
|
+
import_strict.default.deepEqual(
|
|
1579
|
+
(await port.listChanges({ limit: 1 })).map((c) => c.id),
|
|
1580
|
+
[second.id]
|
|
1581
|
+
);
|
|
1582
|
+
});
|
|
1583
|
+
(0, import_node_test.test)("a change whose record has moved on is refused whole: no change, and the record as it was", async (t) => {
|
|
1584
|
+
const port = harness.adapter.appliedSettings;
|
|
1585
|
+
if (!port) {
|
|
1586
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
1590
|
+
const refused = await port.recordChange(
|
|
1591
|
+
changeTo(TWENTY, SECOND_START),
|
|
1592
|
+
applied("sha256-b", SECOND_START, TWENTY),
|
|
1593
|
+
"sha256-stale"
|
|
1594
|
+
);
|
|
1595
|
+
import_strict.default.equal(refused, null);
|
|
1596
|
+
import_strict.default.deepEqual(await port.listChanges(), [], "no change without its record");
|
|
1597
|
+
const read = await port.readApplied();
|
|
1598
|
+
import_strict.default.equal(read?.fingerprint, "sha256-a");
|
|
1599
|
+
import_strict.default.equal(read?.appliedAt.toISOString(), FIRST_START.toISOString());
|
|
1600
|
+
});
|
|
1601
|
+
(0, import_node_test.test)("starts noticing the same difference at once record it once", async (t) => {
|
|
1602
|
+
const port = harness.adapter.appliedSettings;
|
|
1603
|
+
if (!port) {
|
|
1604
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
1608
|
+
const attempts = await Promise.all(
|
|
1609
|
+
[1, 2, 3].map(
|
|
1610
|
+
() => port.recordChange(
|
|
1611
|
+
changeTo(TWENTY, SECOND_START),
|
|
1612
|
+
applied("sha256-b", SECOND_START, TWENTY),
|
|
1613
|
+
"sha256-a"
|
|
1614
|
+
)
|
|
1615
|
+
)
|
|
1616
|
+
);
|
|
1617
|
+
const recorded = attempts.filter((change) => change !== null);
|
|
1618
|
+
import_strict.default.equal(recorded.length, 1, "exactly one start records the change");
|
|
1619
|
+
import_strict.default.deepEqual(
|
|
1620
|
+
(await port.listChanges()).map((c) => c.id),
|
|
1621
|
+
[recorded[0]?.id]
|
|
1622
|
+
);
|
|
1623
|
+
import_strict.default.equal((await port.readApplied())?.fingerprint, "sha256-b");
|
|
1624
|
+
});
|
|
1625
|
+
(0, import_node_test.test)("several first starts write the record once", async (t) => {
|
|
1626
|
+
const port = harness.adapter.appliedSettings;
|
|
1627
|
+
if (!port) {
|
|
1628
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const written = await Promise.all(
|
|
1632
|
+
[1, 2, 3].map(() => port.writeApplied(applied("sha256-a", FIRST_START), null))
|
|
1633
|
+
);
|
|
1634
|
+
import_strict.default.equal(written.filter(Boolean).length, 1, "exactly one start writes it");
|
|
1635
|
+
import_strict.default.equal((await port.readApplied())?.fingerprint, "sha256-a");
|
|
1636
|
+
});
|
|
1637
|
+
(0, import_node_test.test)("changes are listed in the order they were recorded, latest first \u2014 not by the moment they carry", async (t) => {
|
|
1638
|
+
const port = harness.adapter.appliedSettings;
|
|
1639
|
+
if (!port) {
|
|
1640
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
await port.writeApplied(applied("sha256-0", FIRST_START, {}), null);
|
|
1644
|
+
const earlierMove = await port.recordChange(
|
|
1645
|
+
changeTo({ vatRate: 1 }, SECOND_START, {}),
|
|
1646
|
+
applied("sha256-1", SECOND_START, { vatRate: 1 }),
|
|
1647
|
+
"sha256-0"
|
|
1648
|
+
);
|
|
1649
|
+
const laterMove = await port.recordChange(
|
|
1650
|
+
changeTo({ vatRate: 2 }, FIRST_START, { vatRate: 1 }),
|
|
1651
|
+
applied("sha256-2", FIRST_START, { vatRate: 2 }),
|
|
1652
|
+
"sha256-1"
|
|
1653
|
+
);
|
|
1654
|
+
import_strict.default.ok(earlierMove && laterMove, "both guards held");
|
|
1655
|
+
import_strict.default.deepEqual(
|
|
1656
|
+
(await port.listChanges()).map((c) => c.id),
|
|
1657
|
+
[laterMove.id, earlierMove.id]
|
|
1658
|
+
);
|
|
1659
|
+
import_strict.default.deepEqual(
|
|
1660
|
+
(await port.listChanges({ limit: 1 })).map((c) => c.id),
|
|
1661
|
+
[laterMove.id]
|
|
1662
|
+
);
|
|
1663
|
+
import_strict.default.deepEqual(
|
|
1664
|
+
(await port.listChanges()).map((c) => c.id),
|
|
1665
|
+
[laterMove.id, earlierMove.id],
|
|
1666
|
+
"the same order the second time it is asked"
|
|
1667
|
+
);
|
|
1668
|
+
});
|
|
1669
|
+
(0, import_node_test.test)("acknowledging a change is recorded once, and filters it out of what is owed", async (t) => {
|
|
1670
|
+
const port = harness.adapter.appliedSettings;
|
|
1671
|
+
if (!port) {
|
|
1672
|
+
t.skip("adapter provides no AppliedSettingsPort");
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
1676
|
+
const change = await port.recordChange(
|
|
1677
|
+
changeTo(TWENTY, FIRST_START),
|
|
1678
|
+
applied("sha256-b", FIRST_START, TWENTY),
|
|
1679
|
+
"sha256-a"
|
|
1680
|
+
);
|
|
1681
|
+
const open = await port.recordChange(
|
|
1682
|
+
changeTo(TWENTY_ONE, SECOND_START, TWENTY),
|
|
1683
|
+
applied("sha256-c", SECOND_START, TWENTY_ONE),
|
|
1684
|
+
"sha256-b"
|
|
1685
|
+
);
|
|
1686
|
+
import_strict.default.ok(change && open, "both guards held");
|
|
1687
|
+
const seenAt = /* @__PURE__ */ new Date("2026-09-03T08:00:00.000Z");
|
|
1688
|
+
const acknowledged = await port.acknowledgeChange(
|
|
1689
|
+
change.id,
|
|
1690
|
+
"web:ops@example.com:s1",
|
|
1691
|
+
seenAt
|
|
1692
|
+
);
|
|
1693
|
+
import_strict.default.equal(acknowledged?.acknowledgedAt?.toISOString(), seenAt.toISOString());
|
|
1694
|
+
import_strict.default.equal(acknowledged?.acknowledgedBy, "web:ops@example.com:s1");
|
|
1695
|
+
const again = await port.acknowledgeChange(
|
|
1696
|
+
change.id,
|
|
1697
|
+
"web:other@example.com:s2",
|
|
1698
|
+
/* @__PURE__ */ new Date("2026-09-04T08:00:00.000Z")
|
|
1699
|
+
);
|
|
1700
|
+
import_strict.default.equal(again?.acknowledgedAt?.toISOString(), seenAt.toISOString());
|
|
1701
|
+
import_strict.default.equal(again?.acknowledgedBy, "web:ops@example.com:s1");
|
|
1702
|
+
import_strict.default.deepEqual(
|
|
1703
|
+
(await port.listChanges({ acknowledged: false })).map((c) => c.id),
|
|
1704
|
+
[open.id]
|
|
1705
|
+
);
|
|
1706
|
+
import_strict.default.deepEqual(
|
|
1707
|
+
(await port.listChanges({ acknowledged: true })).map((c) => c.id),
|
|
1708
|
+
[change.id]
|
|
1709
|
+
);
|
|
1710
|
+
import_strict.default.equal(
|
|
1711
|
+
await port.acknowledgeChange("no-such-change", "web:ops@example.com:s1", seenAt),
|
|
1712
|
+
null
|
|
750
1713
|
);
|
|
751
1714
|
});
|
|
752
1715
|
});
|