@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 +4 -1
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +136 -0
- package/dist/index.d.cts +9 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +136 -0
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
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.
|
|
26
|
+
"@saasicat/core": "^1.0.0-rc.15"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^25.6.0",
|