@saasicat/persistence-testing 1.0.0-rc.14 → 1.0.0-rc.16
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 +534 -5
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +534 -5
- 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', 'subscribers', 'checkoutOffers', 'appliedSettings'],
|
|
95
98
|
});
|
|
96
99
|
```
|
|
97
100
|
|
package/dist/.build-stamp
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
1ca79840c535585e92be3594dc7e1b240dfd47e78e98d7577fe19a1cd7e51faf
|
package/dist/index.cjs
CHANGED
|
@@ -38,6 +38,100 @@ 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 partiesWith(subscriberId, legalName) {
|
|
57
|
+
return {
|
|
58
|
+
subscriberId,
|
|
59
|
+
subscriber: {
|
|
60
|
+
customerNumber: "K-10001",
|
|
61
|
+
legalName,
|
|
62
|
+
vatId: "DE123456789",
|
|
63
|
+
taxNumber: null,
|
|
64
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
65
|
+
addressLine2: null,
|
|
66
|
+
postalCode: "10115",
|
|
67
|
+
city: "Berlin",
|
|
68
|
+
country: "DE"
|
|
69
|
+
},
|
|
70
|
+
issuer: {
|
|
71
|
+
legalName: "Example Software GmbH",
|
|
72
|
+
vatId: "DE987654321",
|
|
73
|
+
taxNumber: "12/345/67890",
|
|
74
|
+
addressLine1: "Werkstra\xDFe 5",
|
|
75
|
+
addressLine2: null,
|
|
76
|
+
postalCode: "80331",
|
|
77
|
+
city: "M\xFCnchen",
|
|
78
|
+
country: "DE"
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function subscriberFor(tenantId, legalName) {
|
|
83
|
+
return {
|
|
84
|
+
tenantId,
|
|
85
|
+
legalName,
|
|
86
|
+
vatId: null,
|
|
87
|
+
taxNumber: null,
|
|
88
|
+
addressLine1: null,
|
|
89
|
+
addressLine2: null,
|
|
90
|
+
postalCode: null,
|
|
91
|
+
city: null,
|
|
92
|
+
country: null,
|
|
93
|
+
invoiceEmail: null,
|
|
94
|
+
customerNumberPrefix: ""
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function contractFromOffer(offerId, parties) {
|
|
98
|
+
return {
|
|
99
|
+
tenantId: `tenant-${offerId}`,
|
|
100
|
+
parties,
|
|
101
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
102
|
+
originalOfferId: offerId,
|
|
103
|
+
priceSnapshot: {
|
|
104
|
+
currency: "EUR",
|
|
105
|
+
billingCycle: "monthly",
|
|
106
|
+
subtotalNet: 49,
|
|
107
|
+
discountNet: 0,
|
|
108
|
+
totalNet: 49,
|
|
109
|
+
vatRate: 19,
|
|
110
|
+
totalGross: 58.31
|
|
111
|
+
},
|
|
112
|
+
lineItems: [
|
|
113
|
+
{
|
|
114
|
+
kind: "plan",
|
|
115
|
+
sourceKey: "STANDARD",
|
|
116
|
+
sourceVersionId: null,
|
|
117
|
+
titleSnapshot: "Standard",
|
|
118
|
+
descriptionSnapshot: null,
|
|
119
|
+
quantity: 1,
|
|
120
|
+
unit: null,
|
|
121
|
+
priceNet: 49,
|
|
122
|
+
priceGross: 58.31,
|
|
123
|
+
billingCycle: "monthly",
|
|
124
|
+
currency: "EUR",
|
|
125
|
+
taxRate: 19,
|
|
126
|
+
taxAmount: 9.31,
|
|
127
|
+
minimumTermUntil: null,
|
|
128
|
+
featuresSnapshot: [],
|
|
129
|
+
quotaEffectsSnapshot: {},
|
|
130
|
+
metadata: null
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
};
|
|
134
|
+
}
|
|
41
135
|
var CONTRACT_GAPS = {
|
|
42
136
|
atomicPlanBinding: {
|
|
43
137
|
reason: "adapter does not expose atomic plan-binding writes",
|
|
@@ -135,8 +229,16 @@ var CONTRACT_GAPS = {
|
|
|
135
229
|
present: ({ adapter }) => Boolean(adapter.mfa)
|
|
136
230
|
},
|
|
137
231
|
subscriptionContracts: {
|
|
138
|
-
reason: "adapter provides no SubscriptionContractRepository",
|
|
139
|
-
present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
|
|
232
|
+
reason: "adapter provides no SubscriptionContractRepository, or no subscriber seed for it",
|
|
233
|
+
present: ({ adapter, seed }) => Boolean(adapter.subscriptionContractRepository && seed.createSubscriber)
|
|
234
|
+
},
|
|
235
|
+
subscribers: {
|
|
236
|
+
reason: "adapter provides no SubscriberRepository",
|
|
237
|
+
present: ({ adapter }) => Boolean(adapter.subscriberRepository)
|
|
238
|
+
},
|
|
239
|
+
checkoutOffers: {
|
|
240
|
+
reason: "adapter provides no CheckoutOfferRepository",
|
|
241
|
+
present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
|
|
140
242
|
},
|
|
141
243
|
appliedSettings: {
|
|
142
244
|
reason: "adapter provides no AppliedSettingsPort",
|
|
@@ -1327,14 +1429,18 @@ function persistenceAdapterContract(options) {
|
|
|
1327
1429
|
});
|
|
1328
1430
|
(0, import_node_test.test)("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
|
|
1329
1431
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1330
|
-
|
|
1432
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1433
|
+
if (!contracts || !createSubscriber) {
|
|
1331
1434
|
missing(t, "subscriptionContracts");
|
|
1332
1435
|
return;
|
|
1333
1436
|
}
|
|
1334
1437
|
const tenantId = "tenant-contract-lifecycle";
|
|
1335
1438
|
const signedAt = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
|
|
1439
|
+
const { subscriberId } = await createSubscriber({ legalName: "Meier GmbH" });
|
|
1440
|
+
const parties = partiesWith(subscriberId, "Meier GmbH");
|
|
1336
1441
|
const created = await contracts.create({
|
|
1337
1442
|
tenantId,
|
|
1443
|
+
parties,
|
|
1338
1444
|
effectiveFrom: signedAt,
|
|
1339
1445
|
priceSnapshot: {
|
|
1340
1446
|
currency: "EUR",
|
|
@@ -1395,6 +1501,10 @@ function persistenceAdapterContract(options) {
|
|
|
1395
1501
|
});
|
|
1396
1502
|
import_strict.default.equal(created.status, "active");
|
|
1397
1503
|
import_strict.default.equal(created.tenantId, tenantId);
|
|
1504
|
+
import_strict.default.equal(created.subscriberId, subscriberId);
|
|
1505
|
+
import_strict.default.deepEqual(created.subscriber, parties.subscriber);
|
|
1506
|
+
import_strict.default.deepEqual(created.issuer, parties.issuer);
|
|
1507
|
+
import_strict.default.equal(created.partiesMigrated, false);
|
|
1398
1508
|
import_strict.default.equal(created.lineItems.length, 2);
|
|
1399
1509
|
import_strict.default.deepEqual(created.originalBundleVersionIds, ["bundle-version-1"]);
|
|
1400
1510
|
import_strict.default.deepEqual(created.termsSnapshot, { noticePeriodDays: 30 });
|
|
@@ -1427,6 +1537,8 @@ function persistenceAdapterContract(options) {
|
|
|
1427
1537
|
import_strict.default.equal(bundleLine.metadata, null);
|
|
1428
1538
|
const readBack = await contracts.findById(created.id);
|
|
1429
1539
|
import_strict.default.ok(readBack, "contract expected by id");
|
|
1540
|
+
import_strict.default.deepEqual(readBack.subscriber, parties.subscriber, "the copy is stored");
|
|
1541
|
+
import_strict.default.deepEqual(readBack.issuer, parties.issuer);
|
|
1430
1542
|
import_strict.default.equal(
|
|
1431
1543
|
readBack.lineItems.length,
|
|
1432
1544
|
2,
|
|
@@ -1483,11 +1595,14 @@ function persistenceAdapterContract(options) {
|
|
|
1483
1595
|
});
|
|
1484
1596
|
(0, import_node_test.test)("a successor takes over without erasing the contract it replaces", async (t) => {
|
|
1485
1597
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1486
|
-
|
|
1598
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1599
|
+
if (!contracts || !createSubscriber) {
|
|
1487
1600
|
missing(t, "subscriptionContracts");
|
|
1488
1601
|
return;
|
|
1489
1602
|
}
|
|
1490
1603
|
const tenantId = "tenant-contract-succession";
|
|
1604
|
+
const { subscriberId } = await createSubscriber({ legalName: "Nachfolger KG" });
|
|
1605
|
+
const parties = partiesWith(subscriberId, "Nachfolger KG");
|
|
1491
1606
|
const handover = /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z");
|
|
1492
1607
|
const lineAt = (priceNet, priceGross) => ({
|
|
1493
1608
|
kind: "plan",
|
|
@@ -1519,6 +1634,7 @@ function persistenceAdapterContract(options) {
|
|
|
1519
1634
|
});
|
|
1520
1635
|
const first = await contracts.create({
|
|
1521
1636
|
tenantId,
|
|
1637
|
+
parties,
|
|
1522
1638
|
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1523
1639
|
priceSnapshot: priceAt(19.9, 23.68),
|
|
1524
1640
|
lineItems: [lineAt(19.9, 23.68)]
|
|
@@ -1536,6 +1652,7 @@ function persistenceAdapterContract(options) {
|
|
|
1536
1652
|
});
|
|
1537
1653
|
const second = await contracts.create({
|
|
1538
1654
|
tenantId,
|
|
1655
|
+
parties,
|
|
1539
1656
|
effectiveFrom: handover,
|
|
1540
1657
|
priceSnapshot: priceAt(24.9, 29.63),
|
|
1541
1658
|
lineItems: [lineAt(24.9, 29.63)]
|
|
@@ -1585,13 +1702,17 @@ function persistenceAdapterContract(options) {
|
|
|
1585
1702
|
});
|
|
1586
1703
|
(0, import_node_test.test)("a line keeps the currency and the tax it was booked with", async (t) => {
|
|
1587
1704
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1588
|
-
|
|
1705
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1706
|
+
if (!contracts || !createSubscriber) {
|
|
1589
1707
|
missing(t, "subscriptionContracts");
|
|
1590
1708
|
return;
|
|
1591
1709
|
}
|
|
1592
1710
|
const tenantId = "tenant-contract-money-facts";
|
|
1711
|
+
const { subscriberId } = await createSubscriber({ legalName: "Z\xFCrich AG" });
|
|
1712
|
+
const parties = { ...partiesWith(subscriberId, "Z\xFCrich AG"), issuer: null };
|
|
1593
1713
|
const created = await contracts.create({
|
|
1594
1714
|
tenantId,
|
|
1715
|
+
parties,
|
|
1595
1716
|
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1596
1717
|
priceSnapshot: {
|
|
1597
1718
|
currency: "CHF",
|
|
@@ -1665,6 +1786,414 @@ function persistenceAdapterContract(options) {
|
|
|
1665
1786
|
);
|
|
1666
1787
|
}
|
|
1667
1788
|
});
|
|
1789
|
+
(0, import_node_test.test)("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
|
|
1790
|
+
const { adapter, seed } = harness;
|
|
1791
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
1792
|
+
if (!contracts || !seed.createSubscriber) {
|
|
1793
|
+
missing(t, "subscriptionContracts");
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
if (!adapter.capabilities.transactions) {
|
|
1797
|
+
t.skip("adapter declares no transaction capability");
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
const { subscriberId } = await seed.createSubscriber({ legalName: "Angebot GmbH" });
|
|
1801
|
+
const parties = partiesWith(subscriberId, "Angebot GmbH");
|
|
1802
|
+
await import_strict.default.rejects(
|
|
1803
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
1804
|
+
await contracts.create(contractFromOffer("offer-rolled-back", parties), tx);
|
|
1805
|
+
throw new Error("the conclusion fails after the contract is written");
|
|
1806
|
+
})
|
|
1807
|
+
);
|
|
1808
|
+
import_strict.default.equal(
|
|
1809
|
+
await contracts.findByOriginalOfferId("offer-rolled-back"),
|
|
1810
|
+
null,
|
|
1811
|
+
"the contract outlived its transaction"
|
|
1812
|
+
);
|
|
1813
|
+
const kept = await adapter.transactionRunner.run(
|
|
1814
|
+
(tx) => contracts.create(contractFromOffer("offer-kept", parties), tx)
|
|
1815
|
+
);
|
|
1816
|
+
const found = await contracts.findByOriginalOfferId("offer-kept");
|
|
1817
|
+
import_strict.default.equal(found?.id, kept.id);
|
|
1818
|
+
import_strict.default.equal(found?.lineItems.length, 1, "with its lines");
|
|
1819
|
+
import_strict.default.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
|
|
1820
|
+
});
|
|
1821
|
+
(0, import_node_test.test)("a subscriber is created live for its tenant, and numbered by the database", async (t) => {
|
|
1822
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1823
|
+
if (!subscribers) {
|
|
1824
|
+
missing(t, "subscribers");
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const first = await subscribers.createForTenant({
|
|
1828
|
+
...subscriberFor("tenant-subscriber-first", "Erste Autohaus GmbH"),
|
|
1829
|
+
vatId: "DE123456789",
|
|
1830
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
1831
|
+
postalCode: "10115",
|
|
1832
|
+
city: "Berlin",
|
|
1833
|
+
country: "DE",
|
|
1834
|
+
invoiceEmail: "rechnung@erste.example",
|
|
1835
|
+
customerNumberPrefix: "K-"
|
|
1836
|
+
});
|
|
1837
|
+
const second = await subscribers.createForTenant(
|
|
1838
|
+
subscriberFor("tenant-subscriber-second", "Zweiter Verein e.V.")
|
|
1839
|
+
);
|
|
1840
|
+
import_strict.default.ok(first && second, "both tenants had no subscriber");
|
|
1841
|
+
const numberOf = (customerNumber) => Number(customerNumber.replace("K-", ""));
|
|
1842
|
+
import_strict.default.ok(first.customerNumber.startsWith("K-"), first.customerNumber);
|
|
1843
|
+
import_strict.default.equal(numberOf(second.customerNumber), numberOf(first.customerNumber) + 1);
|
|
1844
|
+
import_strict.default.ok(numberOf(first.customerNumber) >= 10001, first.customerNumber);
|
|
1845
|
+
const found = await subscribers.findByTenantId("tenant-subscriber-first");
|
|
1846
|
+
import_strict.default.deepEqual(
|
|
1847
|
+
found && {
|
|
1848
|
+
id: found.id,
|
|
1849
|
+
customerNumber: found.customerNumber,
|
|
1850
|
+
tenantId: found.tenantId,
|
|
1851
|
+
legalName: found.legalName,
|
|
1852
|
+
vatId: found.vatId,
|
|
1853
|
+
taxNumber: found.taxNumber,
|
|
1854
|
+
addressLine1: found.addressLine1,
|
|
1855
|
+
addressLine2: found.addressLine2,
|
|
1856
|
+
postalCode: found.postalCode,
|
|
1857
|
+
city: found.city,
|
|
1858
|
+
country: found.country,
|
|
1859
|
+
invoiceEmail: found.invoiceEmail,
|
|
1860
|
+
migrated: found.migrated
|
|
1861
|
+
},
|
|
1862
|
+
{
|
|
1863
|
+
id: first.id,
|
|
1864
|
+
customerNumber: first.customerNumber,
|
|
1865
|
+
tenantId: "tenant-subscriber-first",
|
|
1866
|
+
legalName: "Erste Autohaus GmbH",
|
|
1867
|
+
vatId: "DE123456789",
|
|
1868
|
+
// The nullable half, so an adapter writing '' is caught too.
|
|
1869
|
+
taxNumber: null,
|
|
1870
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
1871
|
+
addressLine2: null,
|
|
1872
|
+
postalCode: "10115",
|
|
1873
|
+
city: "Berlin",
|
|
1874
|
+
country: "DE",
|
|
1875
|
+
invoiceEmail: "rechnung@erste.example",
|
|
1876
|
+
migrated: false
|
|
1877
|
+
}
|
|
1878
|
+
);
|
|
1879
|
+
import_strict.default.equal(
|
|
1880
|
+
(await subscribers.findById(second.id))?.tenantId,
|
|
1881
|
+
"tenant-subscriber-second"
|
|
1882
|
+
);
|
|
1883
|
+
import_strict.default.equal(await subscribers.findByTenantId("tenant-without-subscriber"), null);
|
|
1884
|
+
import_strict.default.equal(await subscribers.findById("subscriber-nobody-created"), null);
|
|
1885
|
+
});
|
|
1886
|
+
(0, import_node_test.test)("a tenant has one live subscriber: a second is refused, and the transaction survives it", async (t) => {
|
|
1887
|
+
const { adapter } = harness;
|
|
1888
|
+
const subscribers = adapter.subscriberRepository;
|
|
1889
|
+
if (!subscribers) {
|
|
1890
|
+
missing(t, "subscribers");
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (!adapter.capabilities.transactions) {
|
|
1894
|
+
t.skip("adapter declares no transaction capability");
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
const first = await subscribers.createForTenant(
|
|
1898
|
+
subscriberFor("tenant-one-subscriber", "Einzig GmbH")
|
|
1899
|
+
);
|
|
1900
|
+
const afterRefusal = await adapter.transactionRunner.run(async (tx) => {
|
|
1901
|
+
const refused = await subscribers.createForTenant(
|
|
1902
|
+
subscriberFor("tenant-one-subscriber", "Doppelt GmbH"),
|
|
1903
|
+
tx
|
|
1904
|
+
);
|
|
1905
|
+
import_strict.default.equal(refused, null, "a second live subscriber was created");
|
|
1906
|
+
return subscribers.createForTenant(
|
|
1907
|
+
subscriberFor("tenant-after-refusal", "Danach GmbH"),
|
|
1908
|
+
tx
|
|
1909
|
+
);
|
|
1910
|
+
});
|
|
1911
|
+
import_strict.default.equal(
|
|
1912
|
+
(await subscribers.findByTenantId("tenant-one-subscriber"))?.id,
|
|
1913
|
+
first?.id
|
|
1914
|
+
);
|
|
1915
|
+
import_strict.default.equal(
|
|
1916
|
+
(await subscribers.findByTenantId("tenant-after-refusal"))?.id,
|
|
1917
|
+
afterRefusal?.id,
|
|
1918
|
+
"the transaction did not survive the refusal"
|
|
1919
|
+
);
|
|
1920
|
+
});
|
|
1921
|
+
(0, import_node_test.test)("creating a subscriber for one tenant twice at once ends with one", async (t) => {
|
|
1922
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1923
|
+
if (!subscribers) {
|
|
1924
|
+
missing(t, "subscribers");
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const results = await Promise.all([
|
|
1928
|
+
subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig A")),
|
|
1929
|
+
subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig B"))
|
|
1930
|
+
]);
|
|
1931
|
+
const created = results.filter((result) => result !== null);
|
|
1932
|
+
import_strict.default.equal(created.length, 1, `${created.length} subscribers were created`);
|
|
1933
|
+
import_strict.default.equal((await subscribers.findByTenantId("tenant-at-once"))?.id, created[0].id);
|
|
1934
|
+
});
|
|
1935
|
+
(0, import_node_test.test)("a subscriber written on a transaction is undone with it", async (t) => {
|
|
1936
|
+
const { adapter } = harness;
|
|
1937
|
+
const subscribers = adapter.subscriberRepository;
|
|
1938
|
+
if (!subscribers) {
|
|
1939
|
+
missing(t, "subscribers");
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (!adapter.capabilities.transactions) {
|
|
1943
|
+
t.skip("adapter declares no transaction capability");
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
await import_strict.default.rejects(
|
|
1947
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
1948
|
+
await subscribers.createForTenant(
|
|
1949
|
+
subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH"),
|
|
1950
|
+
tx
|
|
1951
|
+
);
|
|
1952
|
+
throw new Error("the tenant is not created after all");
|
|
1953
|
+
})
|
|
1954
|
+
);
|
|
1955
|
+
import_strict.default.equal(await subscribers.findByTenantId("tenant-rolled-back"), null);
|
|
1956
|
+
import_strict.default.ok(
|
|
1957
|
+
await subscribers.createForTenant(
|
|
1958
|
+
subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH")
|
|
1959
|
+
)
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
(0, import_node_test.test)("a contact change writes what it names and keeps the rest", async (t) => {
|
|
1963
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1964
|
+
if (!subscribers) {
|
|
1965
|
+
missing(t, "subscribers");
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const created = await subscribers.createForTenant({
|
|
1969
|
+
...subscriberFor("tenant-contact", "Kontakt GmbH"),
|
|
1970
|
+
addressLine2: "Hinterhaus",
|
|
1971
|
+
city: "Hamburg"
|
|
1972
|
+
});
|
|
1973
|
+
import_strict.default.ok(created);
|
|
1974
|
+
const changed = await subscribers.updateContact(created.id, {
|
|
1975
|
+
city: "Bremen",
|
|
1976
|
+
addressLine2: null,
|
|
1977
|
+
invoiceEmail: "buchhaltung@kontakt.example"
|
|
1978
|
+
});
|
|
1979
|
+
import_strict.default.deepEqual(
|
|
1980
|
+
changed && [
|
|
1981
|
+
changed.city,
|
|
1982
|
+
changed.addressLine2,
|
|
1983
|
+
changed.invoiceEmail,
|
|
1984
|
+
changed.legalName,
|
|
1985
|
+
changed.tenantId
|
|
1986
|
+
],
|
|
1987
|
+
["Bremen", null, "buchhaltung@kontakt.example", "Kontakt GmbH", "tenant-contact"]
|
|
1988
|
+
);
|
|
1989
|
+
import_strict.default.equal((await subscribers.findById(created.id))?.city, "Bremen");
|
|
1990
|
+
import_strict.default.equal(
|
|
1991
|
+
await subscribers.updateContact("subscriber-nobody-created", { city: "Kiel" }),
|
|
1992
|
+
null
|
|
1993
|
+
);
|
|
1994
|
+
});
|
|
1995
|
+
(0, import_node_test.test)("a correction records the values it replaced, and nothing when nothing moves", async (t) => {
|
|
1996
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1997
|
+
if (!subscribers) {
|
|
1998
|
+
missing(t, "subscribers");
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const created = await subscribers.createForTenant(
|
|
2002
|
+
subscriberFor("tenant-correction", "Mueller GmbH")
|
|
2003
|
+
);
|
|
2004
|
+
import_strict.default.ok(created);
|
|
2005
|
+
const at = (day) => new Date(Date.UTC(2026, 8, day));
|
|
2006
|
+
const first = await subscribers.correctIdentity(created.id, {
|
|
2007
|
+
corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789", taxNumber: null },
|
|
2008
|
+
reason: "Umlaut lost when the registration was typed",
|
|
2009
|
+
correctedBy: "operator:anna",
|
|
2010
|
+
correctedAt: at(1)
|
|
2011
|
+
});
|
|
2012
|
+
import_strict.default.deepEqual(
|
|
2013
|
+
first?.correction && {
|
|
2014
|
+
previous: first.correction.previous,
|
|
2015
|
+
corrected: first.correction.corrected,
|
|
2016
|
+
reason: first.correction.reason,
|
|
2017
|
+
correctedBy: first.correction.correctedBy,
|
|
2018
|
+
correctedAt: first.correction.correctedAt.getTime()
|
|
2019
|
+
},
|
|
2020
|
+
{
|
|
2021
|
+
previous: { legalName: "Mueller GmbH", vatId: null },
|
|
2022
|
+
corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789" },
|
|
2023
|
+
reason: "Umlaut lost when the registration was typed",
|
|
2024
|
+
correctedBy: "operator:anna",
|
|
2025
|
+
correctedAt: at(1).getTime()
|
|
2026
|
+
}
|
|
2027
|
+
);
|
|
2028
|
+
import_strict.default.equal(first?.subscriber.legalName, "M\xFCller GmbH");
|
|
2029
|
+
import_strict.default.equal((await subscribers.findById(created.id))?.vatId, "DE123456789");
|
|
2030
|
+
const unchanged = await subscribers.correctIdentity(created.id, {
|
|
2031
|
+
corrected: { legalName: "M\xFCller GmbH" },
|
|
2032
|
+
reason: "Clicked twice",
|
|
2033
|
+
correctedBy: "operator:anna",
|
|
2034
|
+
correctedAt: at(2)
|
|
2035
|
+
});
|
|
2036
|
+
import_strict.default.equal(
|
|
2037
|
+
unchanged?.correction,
|
|
2038
|
+
null,
|
|
2039
|
+
"a correction that moved nothing was recorded"
|
|
2040
|
+
);
|
|
2041
|
+
await subscribers.correctIdentity(created.id, {
|
|
2042
|
+
corrected: { vatId: "DE999999999" },
|
|
2043
|
+
reason: "Wrong VAT id on the first correction",
|
|
2044
|
+
correctedBy: "operator:ben",
|
|
2045
|
+
correctedAt: at(3)
|
|
2046
|
+
});
|
|
2047
|
+
const listed = await subscribers.listCorrections(created.id);
|
|
2048
|
+
import_strict.default.deepEqual(
|
|
2049
|
+
listed.map((correction) => correction.reason),
|
|
2050
|
+
[
|
|
2051
|
+
"Wrong VAT id on the first correction",
|
|
2052
|
+
"Umlaut lost when the registration was typed"
|
|
2053
|
+
],
|
|
2054
|
+
"corrections come back the latest first, and only the two that moved something"
|
|
2055
|
+
);
|
|
2056
|
+
import_strict.default.equal(
|
|
2057
|
+
await subscribers.correctIdentity("subscriber-nobody-created", {
|
|
2058
|
+
corrected: { legalName: "Niemand" },
|
|
2059
|
+
reason: "none",
|
|
2060
|
+
correctedBy: "operator:anna",
|
|
2061
|
+
correctedAt: at(4)
|
|
2062
|
+
}),
|
|
2063
|
+
null
|
|
2064
|
+
);
|
|
2065
|
+
});
|
|
2066
|
+
(0, import_node_test.test)("two corrections at once each record the value the other left behind", async (t) => {
|
|
2067
|
+
const { adapter } = harness;
|
|
2068
|
+
const subscribers = adapter.subscriberRepository;
|
|
2069
|
+
if (!subscribers) {
|
|
2070
|
+
missing(t, "subscribers");
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (!adapter.capabilities.transactions || !adapter.capabilities.pessimisticLocking) {
|
|
2074
|
+
t.skip("adapter declares no transactions or no row locks");
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
const created = await subscribers.createForTenant(
|
|
2078
|
+
subscriberFor("tenant-corrected-twice", "Original GmbH")
|
|
2079
|
+
);
|
|
2080
|
+
import_strict.default.ok(created);
|
|
2081
|
+
const correctTo = (legalName) => adapter.transactionRunner.run(async (tx) => {
|
|
2082
|
+
await subscribers.correctIdentity(
|
|
2083
|
+
created.id,
|
|
2084
|
+
{
|
|
2085
|
+
corrected: { legalName },
|
|
2086
|
+
reason: `to ${legalName}`,
|
|
2087
|
+
correctedBy: "operator:anna",
|
|
2088
|
+
correctedAt: /* @__PURE__ */ new Date()
|
|
2089
|
+
},
|
|
2090
|
+
tx
|
|
2091
|
+
);
|
|
2092
|
+
await sleep(LOCK_HOLD_MS);
|
|
2093
|
+
});
|
|
2094
|
+
await Promise.all([
|
|
2095
|
+
correctTo("Erste Korrektur GmbH"),
|
|
2096
|
+
correctTo("Zweite Korrektur GmbH")
|
|
2097
|
+
]);
|
|
2098
|
+
const listed = await subscribers.listCorrections(created.id);
|
|
2099
|
+
import_strict.default.equal(listed.length, 2);
|
|
2100
|
+
const replaced = listed.map((correction) => correction.previous.legalName).sort();
|
|
2101
|
+
const written = listed.map((correction) => correction.corrected.legalName);
|
|
2102
|
+
const current = (await subscribers.findById(created.id))?.legalName;
|
|
2103
|
+
import_strict.default.ok(replaced.includes("Original GmbH"), JSON.stringify(listed));
|
|
2104
|
+
const secondReplaced = replaced.find((name) => name !== "Original GmbH");
|
|
2105
|
+
import_strict.default.ok(
|
|
2106
|
+
secondReplaced !== void 0 && written.includes(secondReplaced),
|
|
2107
|
+
`a correction recorded a value it did not replace: ${JSON.stringify(listed)}`
|
|
2108
|
+
);
|
|
2109
|
+
import_strict.default.ok(current !== void 0 && written.includes(current));
|
|
2110
|
+
import_strict.default.notEqual(secondReplaced, current);
|
|
2111
|
+
});
|
|
2112
|
+
(0, import_node_test.test)("a contract keeps its subscriber's copy after the subscriber is corrected", async (t) => {
|
|
2113
|
+
const { adapter } = harness;
|
|
2114
|
+
const subscribers = adapter.subscriberRepository;
|
|
2115
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
2116
|
+
if (!subscribers) {
|
|
2117
|
+
missing(t, "subscribers");
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (!contracts || !harness.seed.createSubscriber) {
|
|
2121
|
+
missing(t, "subscriptionContracts");
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const subscriber = await subscribers.createForTenant(
|
|
2125
|
+
subscriberFor("tenant-copy-kept", "Vorher GmbH")
|
|
2126
|
+
);
|
|
2127
|
+
import_strict.default.ok(subscriber);
|
|
2128
|
+
const parties = partiesWith(subscriber.id, "Vorher GmbH");
|
|
2129
|
+
const contract = await contracts.create({
|
|
2130
|
+
...contractFromOffer("offer-copy-kept", parties),
|
|
2131
|
+
tenantId: "tenant-copy-kept"
|
|
2132
|
+
});
|
|
2133
|
+
await subscribers.correctIdentity(subscriber.id, {
|
|
2134
|
+
corrected: { legalName: "Nachher GmbH" },
|
|
2135
|
+
reason: "Change of name of the same company",
|
|
2136
|
+
correctedBy: "operator:anna",
|
|
2137
|
+
correctedAt: /* @__PURE__ */ new Date()
|
|
2138
|
+
});
|
|
2139
|
+
const readBack = await contracts.findById(contract.id);
|
|
2140
|
+
import_strict.default.equal(readBack?.subscriberId, subscriber.id);
|
|
2141
|
+
import_strict.default.equal(
|
|
2142
|
+
readBack?.subscriber.legalName,
|
|
2143
|
+
"Vorher GmbH",
|
|
2144
|
+
"the contract followed a correction of the live record"
|
|
2145
|
+
);
|
|
2146
|
+
});
|
|
2147
|
+
(0, import_node_test.test)("an offer is consumed once, whoever asks first", async (t) => {
|
|
2148
|
+
const offers = harness.adapter.checkoutOfferRepository;
|
|
2149
|
+
if (!offers) {
|
|
2150
|
+
missing(t, "checkoutOffers");
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const offer = await offers.create(OFFER);
|
|
2154
|
+
import_strict.default.equal(offer.status, "open");
|
|
2155
|
+
const attempts = await Promise.allSettled([
|
|
2156
|
+
offers.consume(offer.id),
|
|
2157
|
+
offers.consume(offer.id)
|
|
2158
|
+
]);
|
|
2159
|
+
import_strict.default.equal(
|
|
2160
|
+
attempts.filter((attempt) => attempt.status === "fulfilled").length,
|
|
2161
|
+
1,
|
|
2162
|
+
"exactly one consume wins"
|
|
2163
|
+
);
|
|
2164
|
+
const consumed = await offers.findById(offer.id);
|
|
2165
|
+
import_strict.default.equal(consumed?.status, "consumed");
|
|
2166
|
+
import_strict.default.ok(consumed?.consumedAt, "and it records when");
|
|
2167
|
+
await import_strict.default.rejects(() => offers.consume(offer.id), "a later consume is refused");
|
|
2168
|
+
import_strict.default.equal(
|
|
2169
|
+
(await offers.findById(offer.id))?.consumedAt,
|
|
2170
|
+
consumed.consumedAt,
|
|
2171
|
+
"and changes nothing"
|
|
2172
|
+
);
|
|
2173
|
+
});
|
|
2174
|
+
(0, import_node_test.test)("a consume on a transaction that rolls back leaves the offer open", async (t) => {
|
|
2175
|
+
const { adapter } = harness;
|
|
2176
|
+
const offers = adapter.checkoutOfferRepository;
|
|
2177
|
+
if (!offers) {
|
|
2178
|
+
missing(t, "checkoutOffers");
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
if (!adapter.capabilities.transactions) {
|
|
2182
|
+
t.skip("adapter declares no transaction capability");
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
const offer = await offers.create(OFFER);
|
|
2186
|
+
await import_strict.default.rejects(
|
|
2187
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2188
|
+
await offers.consume(offer.id, tx);
|
|
2189
|
+
throw new Error("the contract after the consume fails");
|
|
2190
|
+
})
|
|
2191
|
+
);
|
|
2192
|
+
const afterwards = await offers.findById(offer.id);
|
|
2193
|
+
import_strict.default.equal(afterwards?.status, "open", "the consume outlived its transaction");
|
|
2194
|
+
import_strict.default.equal(afterwards?.consumedAt, null);
|
|
2195
|
+
import_strict.default.equal((await offers.consume(offer.id)).status, "consumed");
|
|
2196
|
+
});
|
|
1668
2197
|
const SETTINGS = {
|
|
1669
2198
|
app: { name: "Demo" },
|
|
1670
2199
|
currency: "EUR",
|