@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/dist/index.js
CHANGED
|
@@ -2,6 +2,100 @@
|
|
|
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 partiesWith(subscriberId, legalName) {
|
|
21
|
+
return {
|
|
22
|
+
subscriberId,
|
|
23
|
+
subscriber: {
|
|
24
|
+
customerNumber: "K-10001",
|
|
25
|
+
legalName,
|
|
26
|
+
vatId: "DE123456789",
|
|
27
|
+
taxNumber: null,
|
|
28
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
29
|
+
addressLine2: null,
|
|
30
|
+
postalCode: "10115",
|
|
31
|
+
city: "Berlin",
|
|
32
|
+
country: "DE"
|
|
33
|
+
},
|
|
34
|
+
issuer: {
|
|
35
|
+
legalName: "Example Software GmbH",
|
|
36
|
+
vatId: "DE987654321",
|
|
37
|
+
taxNumber: "12/345/67890",
|
|
38
|
+
addressLine1: "Werkstra\xDFe 5",
|
|
39
|
+
addressLine2: null,
|
|
40
|
+
postalCode: "80331",
|
|
41
|
+
city: "M\xFCnchen",
|
|
42
|
+
country: "DE"
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function subscriberFor(tenantId, legalName) {
|
|
47
|
+
return {
|
|
48
|
+
tenantId,
|
|
49
|
+
legalName,
|
|
50
|
+
vatId: null,
|
|
51
|
+
taxNumber: null,
|
|
52
|
+
addressLine1: null,
|
|
53
|
+
addressLine2: null,
|
|
54
|
+
postalCode: null,
|
|
55
|
+
city: null,
|
|
56
|
+
country: null,
|
|
57
|
+
invoiceEmail: null,
|
|
58
|
+
customerNumberPrefix: ""
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function contractFromOffer(offerId, parties) {
|
|
62
|
+
return {
|
|
63
|
+
tenantId: `tenant-${offerId}`,
|
|
64
|
+
parties,
|
|
65
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
66
|
+
originalOfferId: offerId,
|
|
67
|
+
priceSnapshot: {
|
|
68
|
+
currency: "EUR",
|
|
69
|
+
billingCycle: "monthly",
|
|
70
|
+
subtotalNet: 49,
|
|
71
|
+
discountNet: 0,
|
|
72
|
+
totalNet: 49,
|
|
73
|
+
vatRate: 19,
|
|
74
|
+
totalGross: 58.31
|
|
75
|
+
},
|
|
76
|
+
lineItems: [
|
|
77
|
+
{
|
|
78
|
+
kind: "plan",
|
|
79
|
+
sourceKey: "STANDARD",
|
|
80
|
+
sourceVersionId: null,
|
|
81
|
+
titleSnapshot: "Standard",
|
|
82
|
+
descriptionSnapshot: null,
|
|
83
|
+
quantity: 1,
|
|
84
|
+
unit: null,
|
|
85
|
+
priceNet: 49,
|
|
86
|
+
priceGross: 58.31,
|
|
87
|
+
billingCycle: "monthly",
|
|
88
|
+
currency: "EUR",
|
|
89
|
+
taxRate: 19,
|
|
90
|
+
taxAmount: 9.31,
|
|
91
|
+
minimumTermUntil: null,
|
|
92
|
+
featuresSnapshot: [],
|
|
93
|
+
quotaEffectsSnapshot: {},
|
|
94
|
+
metadata: null
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
5
99
|
var CONTRACT_GAPS = {
|
|
6
100
|
atomicPlanBinding: {
|
|
7
101
|
reason: "adapter does not expose atomic plan-binding writes",
|
|
@@ -99,8 +193,16 @@ var CONTRACT_GAPS = {
|
|
|
99
193
|
present: ({ adapter }) => Boolean(adapter.mfa)
|
|
100
194
|
},
|
|
101
195
|
subscriptionContracts: {
|
|
102
|
-
reason: "adapter provides no SubscriptionContractRepository",
|
|
103
|
-
present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
|
|
196
|
+
reason: "adapter provides no SubscriptionContractRepository, or no subscriber seed for it",
|
|
197
|
+
present: ({ adapter, seed }) => Boolean(adapter.subscriptionContractRepository && seed.createSubscriber)
|
|
198
|
+
},
|
|
199
|
+
subscribers: {
|
|
200
|
+
reason: "adapter provides no SubscriberRepository",
|
|
201
|
+
present: ({ adapter }) => Boolean(adapter.subscriberRepository)
|
|
202
|
+
},
|
|
203
|
+
checkoutOffers: {
|
|
204
|
+
reason: "adapter provides no CheckoutOfferRepository",
|
|
205
|
+
present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
|
|
104
206
|
},
|
|
105
207
|
appliedSettings: {
|
|
106
208
|
reason: "adapter provides no AppliedSettingsPort",
|
|
@@ -1291,14 +1393,18 @@ function persistenceAdapterContract(options) {
|
|
|
1291
1393
|
});
|
|
1292
1394
|
test("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
|
|
1293
1395
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1294
|
-
|
|
1396
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1397
|
+
if (!contracts || !createSubscriber) {
|
|
1295
1398
|
missing(t, "subscriptionContracts");
|
|
1296
1399
|
return;
|
|
1297
1400
|
}
|
|
1298
1401
|
const tenantId = "tenant-contract-lifecycle";
|
|
1299
1402
|
const signedAt = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
|
|
1403
|
+
const { subscriberId } = await createSubscriber({ legalName: "Meier GmbH" });
|
|
1404
|
+
const parties = partiesWith(subscriberId, "Meier GmbH");
|
|
1300
1405
|
const created = await contracts.create({
|
|
1301
1406
|
tenantId,
|
|
1407
|
+
parties,
|
|
1302
1408
|
effectiveFrom: signedAt,
|
|
1303
1409
|
priceSnapshot: {
|
|
1304
1410
|
currency: "EUR",
|
|
@@ -1359,6 +1465,10 @@ function persistenceAdapterContract(options) {
|
|
|
1359
1465
|
});
|
|
1360
1466
|
assert.equal(created.status, "active");
|
|
1361
1467
|
assert.equal(created.tenantId, tenantId);
|
|
1468
|
+
assert.equal(created.subscriberId, subscriberId);
|
|
1469
|
+
assert.deepEqual(created.subscriber, parties.subscriber);
|
|
1470
|
+
assert.deepEqual(created.issuer, parties.issuer);
|
|
1471
|
+
assert.equal(created.partiesMigrated, false);
|
|
1362
1472
|
assert.equal(created.lineItems.length, 2);
|
|
1363
1473
|
assert.deepEqual(created.originalBundleVersionIds, ["bundle-version-1"]);
|
|
1364
1474
|
assert.deepEqual(created.termsSnapshot, { noticePeriodDays: 30 });
|
|
@@ -1391,6 +1501,8 @@ function persistenceAdapterContract(options) {
|
|
|
1391
1501
|
assert.equal(bundleLine.metadata, null);
|
|
1392
1502
|
const readBack = await contracts.findById(created.id);
|
|
1393
1503
|
assert.ok(readBack, "contract expected by id");
|
|
1504
|
+
assert.deepEqual(readBack.subscriber, parties.subscriber, "the copy is stored");
|
|
1505
|
+
assert.deepEqual(readBack.issuer, parties.issuer);
|
|
1394
1506
|
assert.equal(
|
|
1395
1507
|
readBack.lineItems.length,
|
|
1396
1508
|
2,
|
|
@@ -1447,11 +1559,14 @@ function persistenceAdapterContract(options) {
|
|
|
1447
1559
|
});
|
|
1448
1560
|
test("a successor takes over without erasing the contract it replaces", async (t) => {
|
|
1449
1561
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1450
|
-
|
|
1562
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1563
|
+
if (!contracts || !createSubscriber) {
|
|
1451
1564
|
missing(t, "subscriptionContracts");
|
|
1452
1565
|
return;
|
|
1453
1566
|
}
|
|
1454
1567
|
const tenantId = "tenant-contract-succession";
|
|
1568
|
+
const { subscriberId } = await createSubscriber({ legalName: "Nachfolger KG" });
|
|
1569
|
+
const parties = partiesWith(subscriberId, "Nachfolger KG");
|
|
1455
1570
|
const handover = /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z");
|
|
1456
1571
|
const lineAt = (priceNet, priceGross) => ({
|
|
1457
1572
|
kind: "plan",
|
|
@@ -1483,6 +1598,7 @@ function persistenceAdapterContract(options) {
|
|
|
1483
1598
|
});
|
|
1484
1599
|
const first = await contracts.create({
|
|
1485
1600
|
tenantId,
|
|
1601
|
+
parties,
|
|
1486
1602
|
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1487
1603
|
priceSnapshot: priceAt(19.9, 23.68),
|
|
1488
1604
|
lineItems: [lineAt(19.9, 23.68)]
|
|
@@ -1500,6 +1616,7 @@ function persistenceAdapterContract(options) {
|
|
|
1500
1616
|
});
|
|
1501
1617
|
const second = await contracts.create({
|
|
1502
1618
|
tenantId,
|
|
1619
|
+
parties,
|
|
1503
1620
|
effectiveFrom: handover,
|
|
1504
1621
|
priceSnapshot: priceAt(24.9, 29.63),
|
|
1505
1622
|
lineItems: [lineAt(24.9, 29.63)]
|
|
@@ -1549,13 +1666,17 @@ function persistenceAdapterContract(options) {
|
|
|
1549
1666
|
});
|
|
1550
1667
|
test("a line keeps the currency and the tax it was booked with", async (t) => {
|
|
1551
1668
|
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1552
|
-
|
|
1669
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1670
|
+
if (!contracts || !createSubscriber) {
|
|
1553
1671
|
missing(t, "subscriptionContracts");
|
|
1554
1672
|
return;
|
|
1555
1673
|
}
|
|
1556
1674
|
const tenantId = "tenant-contract-money-facts";
|
|
1675
|
+
const { subscriberId } = await createSubscriber({ legalName: "Z\xFCrich AG" });
|
|
1676
|
+
const parties = { ...partiesWith(subscriberId, "Z\xFCrich AG"), issuer: null };
|
|
1557
1677
|
const created = await contracts.create({
|
|
1558
1678
|
tenantId,
|
|
1679
|
+
parties,
|
|
1559
1680
|
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1560
1681
|
priceSnapshot: {
|
|
1561
1682
|
currency: "CHF",
|
|
@@ -1629,6 +1750,414 @@ function persistenceAdapterContract(options) {
|
|
|
1629
1750
|
);
|
|
1630
1751
|
}
|
|
1631
1752
|
});
|
|
1753
|
+
test("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
|
|
1754
|
+
const { adapter, seed } = harness;
|
|
1755
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
1756
|
+
if (!contracts || !seed.createSubscriber) {
|
|
1757
|
+
missing(t, "subscriptionContracts");
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (!adapter.capabilities.transactions) {
|
|
1761
|
+
t.skip("adapter declares no transaction capability");
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const { subscriberId } = await seed.createSubscriber({ legalName: "Angebot GmbH" });
|
|
1765
|
+
const parties = partiesWith(subscriberId, "Angebot GmbH");
|
|
1766
|
+
await assert.rejects(
|
|
1767
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
1768
|
+
await contracts.create(contractFromOffer("offer-rolled-back", parties), tx);
|
|
1769
|
+
throw new Error("the conclusion fails after the contract is written");
|
|
1770
|
+
})
|
|
1771
|
+
);
|
|
1772
|
+
assert.equal(
|
|
1773
|
+
await contracts.findByOriginalOfferId("offer-rolled-back"),
|
|
1774
|
+
null,
|
|
1775
|
+
"the contract outlived its transaction"
|
|
1776
|
+
);
|
|
1777
|
+
const kept = await adapter.transactionRunner.run(
|
|
1778
|
+
(tx) => contracts.create(contractFromOffer("offer-kept", parties), tx)
|
|
1779
|
+
);
|
|
1780
|
+
const found = await contracts.findByOriginalOfferId("offer-kept");
|
|
1781
|
+
assert.equal(found?.id, kept.id);
|
|
1782
|
+
assert.equal(found?.lineItems.length, 1, "with its lines");
|
|
1783
|
+
assert.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
|
|
1784
|
+
});
|
|
1785
|
+
test("a subscriber is created live for its tenant, and numbered by the database", async (t) => {
|
|
1786
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1787
|
+
if (!subscribers) {
|
|
1788
|
+
missing(t, "subscribers");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const first = await subscribers.createForTenant({
|
|
1792
|
+
...subscriberFor("tenant-subscriber-first", "Erste Autohaus GmbH"),
|
|
1793
|
+
vatId: "DE123456789",
|
|
1794
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
1795
|
+
postalCode: "10115",
|
|
1796
|
+
city: "Berlin",
|
|
1797
|
+
country: "DE",
|
|
1798
|
+
invoiceEmail: "rechnung@erste.example",
|
|
1799
|
+
customerNumberPrefix: "K-"
|
|
1800
|
+
});
|
|
1801
|
+
const second = await subscribers.createForTenant(
|
|
1802
|
+
subscriberFor("tenant-subscriber-second", "Zweiter Verein e.V.")
|
|
1803
|
+
);
|
|
1804
|
+
assert.ok(first && second, "both tenants had no subscriber");
|
|
1805
|
+
const numberOf = (customerNumber) => Number(customerNumber.replace("K-", ""));
|
|
1806
|
+
assert.ok(first.customerNumber.startsWith("K-"), first.customerNumber);
|
|
1807
|
+
assert.equal(numberOf(second.customerNumber), numberOf(first.customerNumber) + 1);
|
|
1808
|
+
assert.ok(numberOf(first.customerNumber) >= 10001, first.customerNumber);
|
|
1809
|
+
const found = await subscribers.findByTenantId("tenant-subscriber-first");
|
|
1810
|
+
assert.deepEqual(
|
|
1811
|
+
found && {
|
|
1812
|
+
id: found.id,
|
|
1813
|
+
customerNumber: found.customerNumber,
|
|
1814
|
+
tenantId: found.tenantId,
|
|
1815
|
+
legalName: found.legalName,
|
|
1816
|
+
vatId: found.vatId,
|
|
1817
|
+
taxNumber: found.taxNumber,
|
|
1818
|
+
addressLine1: found.addressLine1,
|
|
1819
|
+
addressLine2: found.addressLine2,
|
|
1820
|
+
postalCode: found.postalCode,
|
|
1821
|
+
city: found.city,
|
|
1822
|
+
country: found.country,
|
|
1823
|
+
invoiceEmail: found.invoiceEmail,
|
|
1824
|
+
migrated: found.migrated
|
|
1825
|
+
},
|
|
1826
|
+
{
|
|
1827
|
+
id: first.id,
|
|
1828
|
+
customerNumber: first.customerNumber,
|
|
1829
|
+
tenantId: "tenant-subscriber-first",
|
|
1830
|
+
legalName: "Erste Autohaus GmbH",
|
|
1831
|
+
vatId: "DE123456789",
|
|
1832
|
+
// The nullable half, so an adapter writing '' is caught too.
|
|
1833
|
+
taxNumber: null,
|
|
1834
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
1835
|
+
addressLine2: null,
|
|
1836
|
+
postalCode: "10115",
|
|
1837
|
+
city: "Berlin",
|
|
1838
|
+
country: "DE",
|
|
1839
|
+
invoiceEmail: "rechnung@erste.example",
|
|
1840
|
+
migrated: false
|
|
1841
|
+
}
|
|
1842
|
+
);
|
|
1843
|
+
assert.equal(
|
|
1844
|
+
(await subscribers.findById(second.id))?.tenantId,
|
|
1845
|
+
"tenant-subscriber-second"
|
|
1846
|
+
);
|
|
1847
|
+
assert.equal(await subscribers.findByTenantId("tenant-without-subscriber"), null);
|
|
1848
|
+
assert.equal(await subscribers.findById("subscriber-nobody-created"), null);
|
|
1849
|
+
});
|
|
1850
|
+
test("a tenant has one live subscriber: a second is refused, and the transaction survives it", async (t) => {
|
|
1851
|
+
const { adapter } = harness;
|
|
1852
|
+
const subscribers = adapter.subscriberRepository;
|
|
1853
|
+
if (!subscribers) {
|
|
1854
|
+
missing(t, "subscribers");
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (!adapter.capabilities.transactions) {
|
|
1858
|
+
t.skip("adapter declares no transaction capability");
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
const first = await subscribers.createForTenant(
|
|
1862
|
+
subscriberFor("tenant-one-subscriber", "Einzig GmbH")
|
|
1863
|
+
);
|
|
1864
|
+
const afterRefusal = await adapter.transactionRunner.run(async (tx) => {
|
|
1865
|
+
const refused = await subscribers.createForTenant(
|
|
1866
|
+
subscriberFor("tenant-one-subscriber", "Doppelt GmbH"),
|
|
1867
|
+
tx
|
|
1868
|
+
);
|
|
1869
|
+
assert.equal(refused, null, "a second live subscriber was created");
|
|
1870
|
+
return subscribers.createForTenant(
|
|
1871
|
+
subscriberFor("tenant-after-refusal", "Danach GmbH"),
|
|
1872
|
+
tx
|
|
1873
|
+
);
|
|
1874
|
+
});
|
|
1875
|
+
assert.equal(
|
|
1876
|
+
(await subscribers.findByTenantId("tenant-one-subscriber"))?.id,
|
|
1877
|
+
first?.id
|
|
1878
|
+
);
|
|
1879
|
+
assert.equal(
|
|
1880
|
+
(await subscribers.findByTenantId("tenant-after-refusal"))?.id,
|
|
1881
|
+
afterRefusal?.id,
|
|
1882
|
+
"the transaction did not survive the refusal"
|
|
1883
|
+
);
|
|
1884
|
+
});
|
|
1885
|
+
test("creating a subscriber for one tenant twice at once ends with one", async (t) => {
|
|
1886
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1887
|
+
if (!subscribers) {
|
|
1888
|
+
missing(t, "subscribers");
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const results = await Promise.all([
|
|
1892
|
+
subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig A")),
|
|
1893
|
+
subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig B"))
|
|
1894
|
+
]);
|
|
1895
|
+
const created = results.filter((result) => result !== null);
|
|
1896
|
+
assert.equal(created.length, 1, `${created.length} subscribers were created`);
|
|
1897
|
+
assert.equal((await subscribers.findByTenantId("tenant-at-once"))?.id, created[0].id);
|
|
1898
|
+
});
|
|
1899
|
+
test("a subscriber written on a transaction is undone with it", async (t) => {
|
|
1900
|
+
const { adapter } = harness;
|
|
1901
|
+
const subscribers = adapter.subscriberRepository;
|
|
1902
|
+
if (!subscribers) {
|
|
1903
|
+
missing(t, "subscribers");
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (!adapter.capabilities.transactions) {
|
|
1907
|
+
t.skip("adapter declares no transaction capability");
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
await assert.rejects(
|
|
1911
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
1912
|
+
await subscribers.createForTenant(
|
|
1913
|
+
subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH"),
|
|
1914
|
+
tx
|
|
1915
|
+
);
|
|
1916
|
+
throw new Error("the tenant is not created after all");
|
|
1917
|
+
})
|
|
1918
|
+
);
|
|
1919
|
+
assert.equal(await subscribers.findByTenantId("tenant-rolled-back"), null);
|
|
1920
|
+
assert.ok(
|
|
1921
|
+
await subscribers.createForTenant(
|
|
1922
|
+
subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH")
|
|
1923
|
+
)
|
|
1924
|
+
);
|
|
1925
|
+
});
|
|
1926
|
+
test("a contact change writes what it names and keeps the rest", async (t) => {
|
|
1927
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1928
|
+
if (!subscribers) {
|
|
1929
|
+
missing(t, "subscribers");
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const created = await subscribers.createForTenant({
|
|
1933
|
+
...subscriberFor("tenant-contact", "Kontakt GmbH"),
|
|
1934
|
+
addressLine2: "Hinterhaus",
|
|
1935
|
+
city: "Hamburg"
|
|
1936
|
+
});
|
|
1937
|
+
assert.ok(created);
|
|
1938
|
+
const changed = await subscribers.updateContact(created.id, {
|
|
1939
|
+
city: "Bremen",
|
|
1940
|
+
addressLine2: null,
|
|
1941
|
+
invoiceEmail: "buchhaltung@kontakt.example"
|
|
1942
|
+
});
|
|
1943
|
+
assert.deepEqual(
|
|
1944
|
+
changed && [
|
|
1945
|
+
changed.city,
|
|
1946
|
+
changed.addressLine2,
|
|
1947
|
+
changed.invoiceEmail,
|
|
1948
|
+
changed.legalName,
|
|
1949
|
+
changed.tenantId
|
|
1950
|
+
],
|
|
1951
|
+
["Bremen", null, "buchhaltung@kontakt.example", "Kontakt GmbH", "tenant-contact"]
|
|
1952
|
+
);
|
|
1953
|
+
assert.equal((await subscribers.findById(created.id))?.city, "Bremen");
|
|
1954
|
+
assert.equal(
|
|
1955
|
+
await subscribers.updateContact("subscriber-nobody-created", { city: "Kiel" }),
|
|
1956
|
+
null
|
|
1957
|
+
);
|
|
1958
|
+
});
|
|
1959
|
+
test("a correction records the values it replaced, and nothing when nothing moves", async (t) => {
|
|
1960
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1961
|
+
if (!subscribers) {
|
|
1962
|
+
missing(t, "subscribers");
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
const created = await subscribers.createForTenant(
|
|
1966
|
+
subscriberFor("tenant-correction", "Mueller GmbH")
|
|
1967
|
+
);
|
|
1968
|
+
assert.ok(created);
|
|
1969
|
+
const at = (day) => new Date(Date.UTC(2026, 8, day));
|
|
1970
|
+
const first = await subscribers.correctIdentity(created.id, {
|
|
1971
|
+
corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789", taxNumber: null },
|
|
1972
|
+
reason: "Umlaut lost when the registration was typed",
|
|
1973
|
+
correctedBy: "operator:anna",
|
|
1974
|
+
correctedAt: at(1)
|
|
1975
|
+
});
|
|
1976
|
+
assert.deepEqual(
|
|
1977
|
+
first?.correction && {
|
|
1978
|
+
previous: first.correction.previous,
|
|
1979
|
+
corrected: first.correction.corrected,
|
|
1980
|
+
reason: first.correction.reason,
|
|
1981
|
+
correctedBy: first.correction.correctedBy,
|
|
1982
|
+
correctedAt: first.correction.correctedAt.getTime()
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
previous: { legalName: "Mueller GmbH", vatId: null },
|
|
1986
|
+
corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789" },
|
|
1987
|
+
reason: "Umlaut lost when the registration was typed",
|
|
1988
|
+
correctedBy: "operator:anna",
|
|
1989
|
+
correctedAt: at(1).getTime()
|
|
1990
|
+
}
|
|
1991
|
+
);
|
|
1992
|
+
assert.equal(first?.subscriber.legalName, "M\xFCller GmbH");
|
|
1993
|
+
assert.equal((await subscribers.findById(created.id))?.vatId, "DE123456789");
|
|
1994
|
+
const unchanged = await subscribers.correctIdentity(created.id, {
|
|
1995
|
+
corrected: { legalName: "M\xFCller GmbH" },
|
|
1996
|
+
reason: "Clicked twice",
|
|
1997
|
+
correctedBy: "operator:anna",
|
|
1998
|
+
correctedAt: at(2)
|
|
1999
|
+
});
|
|
2000
|
+
assert.equal(
|
|
2001
|
+
unchanged?.correction,
|
|
2002
|
+
null,
|
|
2003
|
+
"a correction that moved nothing was recorded"
|
|
2004
|
+
);
|
|
2005
|
+
await subscribers.correctIdentity(created.id, {
|
|
2006
|
+
corrected: { vatId: "DE999999999" },
|
|
2007
|
+
reason: "Wrong VAT id on the first correction",
|
|
2008
|
+
correctedBy: "operator:ben",
|
|
2009
|
+
correctedAt: at(3)
|
|
2010
|
+
});
|
|
2011
|
+
const listed = await subscribers.listCorrections(created.id);
|
|
2012
|
+
assert.deepEqual(
|
|
2013
|
+
listed.map((correction) => correction.reason),
|
|
2014
|
+
[
|
|
2015
|
+
"Wrong VAT id on the first correction",
|
|
2016
|
+
"Umlaut lost when the registration was typed"
|
|
2017
|
+
],
|
|
2018
|
+
"corrections come back the latest first, and only the two that moved something"
|
|
2019
|
+
);
|
|
2020
|
+
assert.equal(
|
|
2021
|
+
await subscribers.correctIdentity("subscriber-nobody-created", {
|
|
2022
|
+
corrected: { legalName: "Niemand" },
|
|
2023
|
+
reason: "none",
|
|
2024
|
+
correctedBy: "operator:anna",
|
|
2025
|
+
correctedAt: at(4)
|
|
2026
|
+
}),
|
|
2027
|
+
null
|
|
2028
|
+
);
|
|
2029
|
+
});
|
|
2030
|
+
test("two corrections at once each record the value the other left behind", async (t) => {
|
|
2031
|
+
const { adapter } = harness;
|
|
2032
|
+
const subscribers = adapter.subscriberRepository;
|
|
2033
|
+
if (!subscribers) {
|
|
2034
|
+
missing(t, "subscribers");
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (!adapter.capabilities.transactions || !adapter.capabilities.pessimisticLocking) {
|
|
2038
|
+
t.skip("adapter declares no transactions or no row locks");
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
const created = await subscribers.createForTenant(
|
|
2042
|
+
subscriberFor("tenant-corrected-twice", "Original GmbH")
|
|
2043
|
+
);
|
|
2044
|
+
assert.ok(created);
|
|
2045
|
+
const correctTo = (legalName) => adapter.transactionRunner.run(async (tx) => {
|
|
2046
|
+
await subscribers.correctIdentity(
|
|
2047
|
+
created.id,
|
|
2048
|
+
{
|
|
2049
|
+
corrected: { legalName },
|
|
2050
|
+
reason: `to ${legalName}`,
|
|
2051
|
+
correctedBy: "operator:anna",
|
|
2052
|
+
correctedAt: /* @__PURE__ */ new Date()
|
|
2053
|
+
},
|
|
2054
|
+
tx
|
|
2055
|
+
);
|
|
2056
|
+
await sleep(LOCK_HOLD_MS);
|
|
2057
|
+
});
|
|
2058
|
+
await Promise.all([
|
|
2059
|
+
correctTo("Erste Korrektur GmbH"),
|
|
2060
|
+
correctTo("Zweite Korrektur GmbH")
|
|
2061
|
+
]);
|
|
2062
|
+
const listed = await subscribers.listCorrections(created.id);
|
|
2063
|
+
assert.equal(listed.length, 2);
|
|
2064
|
+
const replaced = listed.map((correction) => correction.previous.legalName).sort();
|
|
2065
|
+
const written = listed.map((correction) => correction.corrected.legalName);
|
|
2066
|
+
const current = (await subscribers.findById(created.id))?.legalName;
|
|
2067
|
+
assert.ok(replaced.includes("Original GmbH"), JSON.stringify(listed));
|
|
2068
|
+
const secondReplaced = replaced.find((name) => name !== "Original GmbH");
|
|
2069
|
+
assert.ok(
|
|
2070
|
+
secondReplaced !== void 0 && written.includes(secondReplaced),
|
|
2071
|
+
`a correction recorded a value it did not replace: ${JSON.stringify(listed)}`
|
|
2072
|
+
);
|
|
2073
|
+
assert.ok(current !== void 0 && written.includes(current));
|
|
2074
|
+
assert.notEqual(secondReplaced, current);
|
|
2075
|
+
});
|
|
2076
|
+
test("a contract keeps its subscriber's copy after the subscriber is corrected", async (t) => {
|
|
2077
|
+
const { adapter } = harness;
|
|
2078
|
+
const subscribers = adapter.subscriberRepository;
|
|
2079
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
2080
|
+
if (!subscribers) {
|
|
2081
|
+
missing(t, "subscribers");
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (!contracts || !harness.seed.createSubscriber) {
|
|
2085
|
+
missing(t, "subscriptionContracts");
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const subscriber = await subscribers.createForTenant(
|
|
2089
|
+
subscriberFor("tenant-copy-kept", "Vorher GmbH")
|
|
2090
|
+
);
|
|
2091
|
+
assert.ok(subscriber);
|
|
2092
|
+
const parties = partiesWith(subscriber.id, "Vorher GmbH");
|
|
2093
|
+
const contract = await contracts.create({
|
|
2094
|
+
...contractFromOffer("offer-copy-kept", parties),
|
|
2095
|
+
tenantId: "tenant-copy-kept"
|
|
2096
|
+
});
|
|
2097
|
+
await subscribers.correctIdentity(subscriber.id, {
|
|
2098
|
+
corrected: { legalName: "Nachher GmbH" },
|
|
2099
|
+
reason: "Change of name of the same company",
|
|
2100
|
+
correctedBy: "operator:anna",
|
|
2101
|
+
correctedAt: /* @__PURE__ */ new Date()
|
|
2102
|
+
});
|
|
2103
|
+
const readBack = await contracts.findById(contract.id);
|
|
2104
|
+
assert.equal(readBack?.subscriberId, subscriber.id);
|
|
2105
|
+
assert.equal(
|
|
2106
|
+
readBack?.subscriber.legalName,
|
|
2107
|
+
"Vorher GmbH",
|
|
2108
|
+
"the contract followed a correction of the live record"
|
|
2109
|
+
);
|
|
2110
|
+
});
|
|
2111
|
+
test("an offer is consumed once, whoever asks first", async (t) => {
|
|
2112
|
+
const offers = harness.adapter.checkoutOfferRepository;
|
|
2113
|
+
if (!offers) {
|
|
2114
|
+
missing(t, "checkoutOffers");
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const offer = await offers.create(OFFER);
|
|
2118
|
+
assert.equal(offer.status, "open");
|
|
2119
|
+
const attempts = await Promise.allSettled([
|
|
2120
|
+
offers.consume(offer.id),
|
|
2121
|
+
offers.consume(offer.id)
|
|
2122
|
+
]);
|
|
2123
|
+
assert.equal(
|
|
2124
|
+
attempts.filter((attempt) => attempt.status === "fulfilled").length,
|
|
2125
|
+
1,
|
|
2126
|
+
"exactly one consume wins"
|
|
2127
|
+
);
|
|
2128
|
+
const consumed = await offers.findById(offer.id);
|
|
2129
|
+
assert.equal(consumed?.status, "consumed");
|
|
2130
|
+
assert.ok(consumed?.consumedAt, "and it records when");
|
|
2131
|
+
await assert.rejects(() => offers.consume(offer.id), "a later consume is refused");
|
|
2132
|
+
assert.equal(
|
|
2133
|
+
(await offers.findById(offer.id))?.consumedAt,
|
|
2134
|
+
consumed.consumedAt,
|
|
2135
|
+
"and changes nothing"
|
|
2136
|
+
);
|
|
2137
|
+
});
|
|
2138
|
+
test("a consume on a transaction that rolls back leaves the offer open", async (t) => {
|
|
2139
|
+
const { adapter } = harness;
|
|
2140
|
+
const offers = adapter.checkoutOfferRepository;
|
|
2141
|
+
if (!offers) {
|
|
2142
|
+
missing(t, "checkoutOffers");
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (!adapter.capabilities.transactions) {
|
|
2146
|
+
t.skip("adapter declares no transaction capability");
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
const offer = await offers.create(OFFER);
|
|
2150
|
+
await assert.rejects(
|
|
2151
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2152
|
+
await offers.consume(offer.id, tx);
|
|
2153
|
+
throw new Error("the contract after the consume fails");
|
|
2154
|
+
})
|
|
2155
|
+
);
|
|
2156
|
+
const afterwards = await offers.findById(offer.id);
|
|
2157
|
+
assert.equal(afterwards?.status, "open", "the consume outlived its transaction");
|
|
2158
|
+
assert.equal(afterwards?.consumedAt, null);
|
|
2159
|
+
assert.equal((await offers.consume(offer.id)).status, "consumed");
|
|
2160
|
+
});
|
|
1632
2161
|
const SETTINGS = {
|
|
1633
2162
|
app: { name: "Demo" },
|
|
1634
2163
|
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.16",
|
|
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.16"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^25.6.0",
|