@saasicat/persistence-testing 1.0.0-rc.14 → 1.0.0-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,9 @@ Verified scenarios:
16
16
  - PlanVersion and BundleVersion validity windows with auto-succession
17
17
  - `countByPlanVersionId` counts current AND pending bindings in one query
18
18
  - transaction rollback discards writes
19
+ - a contract written on a transaction is undone with it, and found by the offer it came from
20
+ - a checkout offer is consumed once, whoever asks first, and a consume on a rolled-back transaction
21
+ leaves it open
19
22
  - `findByTenantIdLocked` serializes concurrent transactions (row lock)
20
23
  - concurrent `claimSlot` grants exactly `maxRedemptions` slots
21
24
  - claim / exhaust / release lifecycle
@@ -91,7 +94,7 @@ persistenceAdapterContract({
91
94
  }),
92
95
  // The parts this adapter deliberately does not provide. A part named here
93
96
  // that the harness does provide fails the suite as well.
94
- gaps: ['subscriptionContracts', 'appliedSettings'],
97
+ gaps: ['subscriptionContracts', 'checkoutOffers', 'appliedSettings'],
95
98
  });
96
99
  ```
97
100
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- fe1ab043363d0764aaf580f2167335fcb0c2659c445bcc9540708782cb751a27
1
+ 8fdc45ae34c5869710281576820410215b122b89385485ab7973ebfc8359157b
package/dist/index.cjs CHANGED
@@ -38,6 +38,58 @@ module.exports = __toCommonJS(index_exports);
38
38
  var import_strict = __toESM(require("node:assert/strict"), 1);
39
39
  var import_node_test = require("node:test");
40
40
  var LOCK_HOLD_MS = 150;
41
+ var OFFER = {
42
+ planKey: "STANDARD",
43
+ planVersionId: null,
44
+ billingCycle: "monthly",
45
+ priceBreakdown: {
46
+ currency: "EUR",
47
+ billingCycle: "monthly",
48
+ planNet: 49,
49
+ bundlesNet: 0,
50
+ regularNet: 49,
51
+ effectiveNet: 49,
52
+ vatRate: 19,
53
+ effectiveGross: 58.31
54
+ }
55
+ };
56
+ function contractFromOffer(offerId) {
57
+ return {
58
+ tenantId: `tenant-${offerId}`,
59
+ effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
60
+ originalOfferId: offerId,
61
+ priceSnapshot: {
62
+ currency: "EUR",
63
+ billingCycle: "monthly",
64
+ subtotalNet: 49,
65
+ discountNet: 0,
66
+ totalNet: 49,
67
+ vatRate: 19,
68
+ totalGross: 58.31
69
+ },
70
+ lineItems: [
71
+ {
72
+ kind: "plan",
73
+ sourceKey: "STANDARD",
74
+ sourceVersionId: null,
75
+ titleSnapshot: "Standard",
76
+ descriptionSnapshot: null,
77
+ quantity: 1,
78
+ unit: null,
79
+ priceNet: 49,
80
+ priceGross: 58.31,
81
+ billingCycle: "monthly",
82
+ currency: "EUR",
83
+ taxRate: 19,
84
+ taxAmount: 9.31,
85
+ minimumTermUntil: null,
86
+ featuresSnapshot: [],
87
+ quotaEffectsSnapshot: {},
88
+ metadata: null
89
+ }
90
+ ]
91
+ };
92
+ }
41
93
  var CONTRACT_GAPS = {
42
94
  atomicPlanBinding: {
43
95
  reason: "adapter does not expose atomic plan-binding writes",
@@ -138,6 +190,10 @@ var CONTRACT_GAPS = {
138
190
  reason: "adapter provides no SubscriptionContractRepository",
139
191
  present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
140
192
  },
193
+ checkoutOffers: {
194
+ reason: "adapter provides no CheckoutOfferRepository",
195
+ present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
196
+ },
141
197
  appliedSettings: {
142
198
  reason: "adapter provides no AppliedSettingsPort",
143
199
  present: ({ adapter }) => Boolean(adapter.appliedSettings)
@@ -1665,6 +1721,86 @@ function persistenceAdapterContract(options) {
1665
1721
  );
1666
1722
  }
1667
1723
  });
1724
+ (0, import_node_test.test)("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
1725
+ const { adapter } = harness;
1726
+ const contracts = adapter.subscriptionContractRepository;
1727
+ if (!contracts) {
1728
+ missing(t, "subscriptionContracts");
1729
+ return;
1730
+ }
1731
+ if (!adapter.capabilities.transactions) {
1732
+ t.skip("adapter declares no transaction capability");
1733
+ return;
1734
+ }
1735
+ await import_strict.default.rejects(
1736
+ adapter.transactionRunner.run(async (tx) => {
1737
+ await contracts.create(contractFromOffer("offer-rolled-back"), tx);
1738
+ throw new Error("the conclusion fails after the contract is written");
1739
+ })
1740
+ );
1741
+ import_strict.default.equal(
1742
+ await contracts.findByOriginalOfferId("offer-rolled-back"),
1743
+ null,
1744
+ "the contract outlived its transaction"
1745
+ );
1746
+ const kept = await adapter.transactionRunner.run(
1747
+ (tx) => contracts.create(contractFromOffer("offer-kept"), tx)
1748
+ );
1749
+ const found = await contracts.findByOriginalOfferId("offer-kept");
1750
+ import_strict.default.equal(found?.id, kept.id);
1751
+ import_strict.default.equal(found?.lineItems.length, 1, "with its lines");
1752
+ import_strict.default.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
1753
+ });
1754
+ (0, import_node_test.test)("an offer is consumed once, whoever asks first", async (t) => {
1755
+ const offers = harness.adapter.checkoutOfferRepository;
1756
+ if (!offers) {
1757
+ missing(t, "checkoutOffers");
1758
+ return;
1759
+ }
1760
+ const offer = await offers.create(OFFER);
1761
+ import_strict.default.equal(offer.status, "open");
1762
+ const attempts = await Promise.allSettled([
1763
+ offers.consume(offer.id),
1764
+ offers.consume(offer.id)
1765
+ ]);
1766
+ import_strict.default.equal(
1767
+ attempts.filter((attempt) => attempt.status === "fulfilled").length,
1768
+ 1,
1769
+ "exactly one consume wins"
1770
+ );
1771
+ const consumed = await offers.findById(offer.id);
1772
+ import_strict.default.equal(consumed?.status, "consumed");
1773
+ import_strict.default.ok(consumed?.consumedAt, "and it records when");
1774
+ await import_strict.default.rejects(() => offers.consume(offer.id), "a later consume is refused");
1775
+ import_strict.default.equal(
1776
+ (await offers.findById(offer.id))?.consumedAt,
1777
+ consumed.consumedAt,
1778
+ "and changes nothing"
1779
+ );
1780
+ });
1781
+ (0, import_node_test.test)("a consume on a transaction that rolls back leaves the offer open", async (t) => {
1782
+ const { adapter } = harness;
1783
+ const offers = adapter.checkoutOfferRepository;
1784
+ if (!offers) {
1785
+ missing(t, "checkoutOffers");
1786
+ return;
1787
+ }
1788
+ if (!adapter.capabilities.transactions) {
1789
+ t.skip("adapter declares no transaction capability");
1790
+ return;
1791
+ }
1792
+ const offer = await offers.create(OFFER);
1793
+ await import_strict.default.rejects(
1794
+ adapter.transactionRunner.run(async (tx) => {
1795
+ await offers.consume(offer.id, tx);
1796
+ throw new Error("the contract after the consume fails");
1797
+ })
1798
+ );
1799
+ const afterwards = await offers.findById(offer.id);
1800
+ import_strict.default.equal(afterwards?.status, "open", "the consume outlived its transaction");
1801
+ import_strict.default.equal(afterwards?.consumedAt, null);
1802
+ import_strict.default.equal((await offers.consume(offer.id)).status, "consumed");
1803
+ });
1668
1804
  const SETTINGS = {
1669
1805
  app: { name: "Demo" },
1670
1806
  currency: "EUR",
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
1
+ import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
2
2
 
3
3
  /**
4
4
  * Port instances under test. Required members define the minimum an adapter
@@ -18,6 +18,13 @@ interface ContractAdapterInstances {
18
18
  audit?: AuditPort;
19
19
  auditQuery?: AuditQueryPort;
20
20
  subscriptionContractRepository?: SubscriptionContractRepository;
21
+ /**
22
+ * Enables the checkout offer scenarios: an offer is consumed once, and a
23
+ * consume on a transaction that rolls back leaves it open. Neither shipped
24
+ * adapter provides one; an application that implements the port wires it
25
+ * here.
26
+ */
27
+ checkoutOfferRepository?: CheckoutOfferRepository;
21
28
  /**
22
29
  * Enables the atomic plan-binding scenarios. Adapters should expose this
23
30
  * member only for a mode that promises to keep `plan`,
@@ -132,7 +139,7 @@ interface PersistenceContractHarness {
132
139
  * Each names the members its scenarios need; `contract.ts` holds the list with
133
140
  * what each one checks.
134
141
  */
135
- type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'appliedSettings';
142
+ type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'checkoutOffers' | 'appliedSettings';
136
143
  interface PersistenceAdapterContractOptions {
137
144
  /** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
138
145
  name: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
1
+ import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
2
2
 
3
3
  /**
4
4
  * Port instances under test. Required members define the minimum an adapter
@@ -18,6 +18,13 @@ interface ContractAdapterInstances {
18
18
  audit?: AuditPort;
19
19
  auditQuery?: AuditQueryPort;
20
20
  subscriptionContractRepository?: SubscriptionContractRepository;
21
+ /**
22
+ * Enables the checkout offer scenarios: an offer is consumed once, and a
23
+ * consume on a transaction that rolls back leaves it open. Neither shipped
24
+ * adapter provides one; an application that implements the port wires it
25
+ * here.
26
+ */
27
+ checkoutOfferRepository?: CheckoutOfferRepository;
21
28
  /**
22
29
  * Enables the atomic plan-binding scenarios. Adapters should expose this
23
30
  * member only for a mode that promises to keep `plan`,
@@ -132,7 +139,7 @@ interface PersistenceContractHarness {
132
139
  * Each names the members its scenarios need; `contract.ts` holds the list with
133
140
  * what each one checks.
134
141
  */
135
- type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'appliedSettings';
142
+ type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'checkoutOffers' | 'appliedSettings';
136
143
  interface PersistenceAdapterContractOptions {
137
144
  /** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
138
145
  name: string;
package/dist/index.js CHANGED
@@ -2,6 +2,58 @@
2
2
  import assert from "node:assert/strict";
3
3
  import { after, before, beforeEach, describe, test } from "node:test";
4
4
  var LOCK_HOLD_MS = 150;
5
+ var OFFER = {
6
+ planKey: "STANDARD",
7
+ planVersionId: null,
8
+ billingCycle: "monthly",
9
+ priceBreakdown: {
10
+ currency: "EUR",
11
+ billingCycle: "monthly",
12
+ planNet: 49,
13
+ bundlesNet: 0,
14
+ regularNet: 49,
15
+ effectiveNet: 49,
16
+ vatRate: 19,
17
+ effectiveGross: 58.31
18
+ }
19
+ };
20
+ function contractFromOffer(offerId) {
21
+ return {
22
+ tenantId: `tenant-${offerId}`,
23
+ effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
24
+ originalOfferId: offerId,
25
+ priceSnapshot: {
26
+ currency: "EUR",
27
+ billingCycle: "monthly",
28
+ subtotalNet: 49,
29
+ discountNet: 0,
30
+ totalNet: 49,
31
+ vatRate: 19,
32
+ totalGross: 58.31
33
+ },
34
+ lineItems: [
35
+ {
36
+ kind: "plan",
37
+ sourceKey: "STANDARD",
38
+ sourceVersionId: null,
39
+ titleSnapshot: "Standard",
40
+ descriptionSnapshot: null,
41
+ quantity: 1,
42
+ unit: null,
43
+ priceNet: 49,
44
+ priceGross: 58.31,
45
+ billingCycle: "monthly",
46
+ currency: "EUR",
47
+ taxRate: 19,
48
+ taxAmount: 9.31,
49
+ minimumTermUntil: null,
50
+ featuresSnapshot: [],
51
+ quotaEffectsSnapshot: {},
52
+ metadata: null
53
+ }
54
+ ]
55
+ };
56
+ }
5
57
  var CONTRACT_GAPS = {
6
58
  atomicPlanBinding: {
7
59
  reason: "adapter does not expose atomic plan-binding writes",
@@ -102,6 +154,10 @@ var CONTRACT_GAPS = {
102
154
  reason: "adapter provides no SubscriptionContractRepository",
103
155
  present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
104
156
  },
157
+ checkoutOffers: {
158
+ reason: "adapter provides no CheckoutOfferRepository",
159
+ present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
160
+ },
105
161
  appliedSettings: {
106
162
  reason: "adapter provides no AppliedSettingsPort",
107
163
  present: ({ adapter }) => Boolean(adapter.appliedSettings)
@@ -1629,6 +1685,86 @@ function persistenceAdapterContract(options) {
1629
1685
  );
1630
1686
  }
1631
1687
  });
1688
+ test("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
1689
+ const { adapter } = harness;
1690
+ const contracts = adapter.subscriptionContractRepository;
1691
+ if (!contracts) {
1692
+ missing(t, "subscriptionContracts");
1693
+ return;
1694
+ }
1695
+ if (!adapter.capabilities.transactions) {
1696
+ t.skip("adapter declares no transaction capability");
1697
+ return;
1698
+ }
1699
+ await assert.rejects(
1700
+ adapter.transactionRunner.run(async (tx) => {
1701
+ await contracts.create(contractFromOffer("offer-rolled-back"), tx);
1702
+ throw new Error("the conclusion fails after the contract is written");
1703
+ })
1704
+ );
1705
+ assert.equal(
1706
+ await contracts.findByOriginalOfferId("offer-rolled-back"),
1707
+ null,
1708
+ "the contract outlived its transaction"
1709
+ );
1710
+ const kept = await adapter.transactionRunner.run(
1711
+ (tx) => contracts.create(contractFromOffer("offer-kept"), tx)
1712
+ );
1713
+ const found = await contracts.findByOriginalOfferId("offer-kept");
1714
+ assert.equal(found?.id, kept.id);
1715
+ assert.equal(found?.lineItems.length, 1, "with its lines");
1716
+ assert.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
1717
+ });
1718
+ test("an offer is consumed once, whoever asks first", async (t) => {
1719
+ const offers = harness.adapter.checkoutOfferRepository;
1720
+ if (!offers) {
1721
+ missing(t, "checkoutOffers");
1722
+ return;
1723
+ }
1724
+ const offer = await offers.create(OFFER);
1725
+ assert.equal(offer.status, "open");
1726
+ const attempts = await Promise.allSettled([
1727
+ offers.consume(offer.id),
1728
+ offers.consume(offer.id)
1729
+ ]);
1730
+ assert.equal(
1731
+ attempts.filter((attempt) => attempt.status === "fulfilled").length,
1732
+ 1,
1733
+ "exactly one consume wins"
1734
+ );
1735
+ const consumed = await offers.findById(offer.id);
1736
+ assert.equal(consumed?.status, "consumed");
1737
+ assert.ok(consumed?.consumedAt, "and it records when");
1738
+ await assert.rejects(() => offers.consume(offer.id), "a later consume is refused");
1739
+ assert.equal(
1740
+ (await offers.findById(offer.id))?.consumedAt,
1741
+ consumed.consumedAt,
1742
+ "and changes nothing"
1743
+ );
1744
+ });
1745
+ test("a consume on a transaction that rolls back leaves the offer open", async (t) => {
1746
+ const { adapter } = harness;
1747
+ const offers = adapter.checkoutOfferRepository;
1748
+ if (!offers) {
1749
+ missing(t, "checkoutOffers");
1750
+ return;
1751
+ }
1752
+ if (!adapter.capabilities.transactions) {
1753
+ t.skip("adapter declares no transaction capability");
1754
+ return;
1755
+ }
1756
+ const offer = await offers.create(OFFER);
1757
+ await assert.rejects(
1758
+ adapter.transactionRunner.run(async (tx) => {
1759
+ await offers.consume(offer.id, tx);
1760
+ throw new Error("the contract after the consume fails");
1761
+ })
1762
+ );
1763
+ const afterwards = await offers.findById(offer.id);
1764
+ assert.equal(afterwards?.status, "open", "the consume outlived its transaction");
1765
+ assert.equal(afterwards?.consumedAt, null);
1766
+ assert.equal((await offers.consume(offer.id)).status, "consumed");
1767
+ });
1632
1768
  const SETTINGS = {
1633
1769
  app: { name: "Demo" },
1634
1770
  currency: "EUR",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/persistence-testing",
3
- "version": "1.0.0-rc.14",
3
+ "version": "1.0.0-rc.15",
4
4
  "description": "Contract test kit for SaaSiCat persistence adapters: one node:test suite that every adapter (Prisma, Drizzle, ...) must pass against a real database — locks, transaction rollback, atomic promo claims, tenant isolation, audit/MFA roundtrips.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,7 +23,7 @@
23
23
  "dist"
24
24
  ],
25
25
  "dependencies": {
26
- "@saasicat/core": "^1.0.0-rc.14"
26
+ "@saasicat/core": "^1.0.0-rc.15"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.6.0",