@relaybus/testing 0.3.1 → 0.4.0
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 +31 -0
- package/dist/index.cjs +175 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -1
- package/dist/index.d.ts +142 -1
- package/dist/index.js +173 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -40,6 +40,37 @@ claimer that keeps claiming drains the workload. Pass `keepsCompletedRecords:
|
|
|
40
40
|
true` when the store keeps a completion history; run the suite once per mode the
|
|
41
41
|
store supports.
|
|
42
42
|
|
|
43
|
+
## Codec contract
|
|
44
|
+
|
|
45
|
+
Before adopting a wire format that other services already use, prove that a codec writes and reads it exactly. Give
|
|
46
|
+
`kafkaCodecContract` or `rabbitMqCodecContract` captured messages and the events they carry; each case is one test that
|
|
47
|
+
checks `encode(event)` against the captured message (value or body byte for byte or as canonical JSON, key, headers,
|
|
48
|
+
timestamp or properties) and that decoding the captured message gives the event:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { kafkaCodecContract } from '@relaybus/testing';
|
|
52
|
+
import { decodeKafkaRecord } from '@relaybus/kafka';
|
|
53
|
+
|
|
54
|
+
kafkaCodecContract('orders format', {
|
|
55
|
+
encode: orderCodec.encode,
|
|
56
|
+
decode: (record) => decodeKafkaRecord(orderCodec, record),
|
|
57
|
+
compare: 'bytes', // or 'json' to ignore key order and spacing
|
|
58
|
+
ignoreHeaders: ['traceparent'],
|
|
59
|
+
cases: [
|
|
60
|
+
{
|
|
61
|
+
name: 'order placed',
|
|
62
|
+
envelope, // the event
|
|
63
|
+
record: { topic: 'orders', key: 'order-1', headers: {}, value: '{"orderId":"1"}' }, // the captured record
|
|
64
|
+
// only: 'decode', // for a message only other producers write
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`rabbitMqCodecContract` takes the same shape with `decodeRabbitMqMessage` from `@relaybus/rabbitmq` and captured messages
|
|
71
|
+
(routing key, properties, headers, body). `kafkaCodecMismatches` and `rabbitMqCodecMismatches` return the differences as
|
|
72
|
+
text lines instead of registering tests.
|
|
73
|
+
|
|
43
74
|
## FakeClock
|
|
44
75
|
|
|
45
76
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -1406,17 +1406,185 @@ var InMemoryTransport = class {
|
|
|
1406
1406
|
}
|
|
1407
1407
|
};
|
|
1408
1408
|
async function deliverWithRedeliveries(handler, delivery) {
|
|
1409
|
-
for (let
|
|
1409
|
+
for (let attempt2 = 0; ; attempt2 += 1) {
|
|
1410
1410
|
try {
|
|
1411
1411
|
await handler(delivery.envelope, { destination: delivery.destination });
|
|
1412
|
-
return
|
|
1412
|
+
return attempt2 + 1;
|
|
1413
1413
|
} catch (error) {
|
|
1414
|
-
if (
|
|
1414
|
+
if (attempt2 >= delivery.maxRedeliveries) {
|
|
1415
1415
|
throw error;
|
|
1416
1416
|
}
|
|
1417
1417
|
}
|
|
1418
1418
|
}
|
|
1419
1419
|
}
|
|
1420
|
+
function toBuffer(bytes) {
|
|
1421
|
+
return typeof bytes === "string" ? Buffer.from(bytes, "utf8") : Buffer.from(bytes);
|
|
1422
|
+
}
|
|
1423
|
+
function canonicalJson(value) {
|
|
1424
|
+
return JSON.stringify(sortKeys(JSON.parse(JSON.stringify(value) ?? "null")));
|
|
1425
|
+
}
|
|
1426
|
+
function sortKeys(value) {
|
|
1427
|
+
if (Array.isArray(value)) {
|
|
1428
|
+
return value.map(sortKeys);
|
|
1429
|
+
}
|
|
1430
|
+
if (typeof value !== "object" || value === null) {
|
|
1431
|
+
return value;
|
|
1432
|
+
}
|
|
1433
|
+
return Object.fromEntries(
|
|
1434
|
+
Object.keys(value).sort().map((name) => [name, sortKeys(value[name])])
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
function compareBodies(label, {
|
|
1438
|
+
expected,
|
|
1439
|
+
encoded,
|
|
1440
|
+
comparison
|
|
1441
|
+
}) {
|
|
1442
|
+
const text = (bytes) => bytes === null ? "null" : toBuffer(bytes).toString("utf8");
|
|
1443
|
+
const same = expected === null || encoded === null ? expected === encoded : comparison === "json" ? canonicalJson(JSON.parse(text(expected))) === canonicalJson(JSON.parse(text(encoded))) : toBuffer(expected).equals(toBuffer(encoded));
|
|
1444
|
+
return same ? [] : [`${label}: expected ${text(expected)}, encoded ${text(encoded)}`];
|
|
1445
|
+
}
|
|
1446
|
+
function compareObjects(label, {
|
|
1447
|
+
expected,
|
|
1448
|
+
encoded,
|
|
1449
|
+
ignored
|
|
1450
|
+
}) {
|
|
1451
|
+
const kept = (object) => canonicalJson(
|
|
1452
|
+
Object.fromEntries(Object.entries(object).filter(([name]) => !ignored.includes(name)))
|
|
1453
|
+
);
|
|
1454
|
+
const [left, right] = [kept(expected), kept(encoded)];
|
|
1455
|
+
return left === right ? [] : [`${label}: expected ${left}, encoded ${right}`];
|
|
1456
|
+
}
|
|
1457
|
+
function compareDecoded(expected, decoded) {
|
|
1458
|
+
const [left, right] = [canonicalJson(expected), canonicalJson(decoded)];
|
|
1459
|
+
return left === right ? [] : [`decoded: expected ${left}, decoded ${right}`];
|
|
1460
|
+
}
|
|
1461
|
+
async function attempt(label, step) {
|
|
1462
|
+
try {
|
|
1463
|
+
return { result: await step(), mismatches: [] };
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
return { mismatches: [`${label} threw: ${core.toErrorInfo(error).message}`] };
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/codec-contract/kafka.ts
|
|
1470
|
+
async function kafkaCodecMismatches(options, testCase) {
|
|
1471
|
+
const { record } = testCase;
|
|
1472
|
+
const encoding = testCase.only === "decode" ? { mismatches: [] } : await attempt("encode", () => options.encode(testCase.envelope));
|
|
1473
|
+
const encoded = encoding.result;
|
|
1474
|
+
const decoding = testCase.only === "encode" ? { mismatches: [] } : await attempt("decode", () => options.decode(delivered(record)));
|
|
1475
|
+
return [
|
|
1476
|
+
...encoding.mismatches,
|
|
1477
|
+
...encoded === void 0 ? [] : compareRecords(options, { record, encoded }),
|
|
1478
|
+
...decoding.mismatches,
|
|
1479
|
+
...decoding.result === void 0 ? [] : compareDecoded(testCase.decoded ?? testCase.envelope, decoding.result)
|
|
1480
|
+
];
|
|
1481
|
+
}
|
|
1482
|
+
function kafkaCodecContract(name, options) {
|
|
1483
|
+
vitest.describe(`${name} writes and reads the captured Kafka records`, () => {
|
|
1484
|
+
vitest.it.each(options.cases.map((testCase) => [testCase.name, testCase]))(
|
|
1485
|
+
"%s",
|
|
1486
|
+
async (_name, testCase) => {
|
|
1487
|
+
vitest.expect(await kafkaCodecMismatches(options, testCase)).toEqual([]);
|
|
1488
|
+
}
|
|
1489
|
+
);
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
function compareRecords(options, {
|
|
1493
|
+
record,
|
|
1494
|
+
encoded
|
|
1495
|
+
}) {
|
|
1496
|
+
const text = (bytes) => bytes === null ? null : toBuffer(bytes).toString("utf8");
|
|
1497
|
+
const timestamp = encoded.timestamp;
|
|
1498
|
+
return [
|
|
1499
|
+
...text(record.key) === text(encoded.key) ? [] : [`key: expected ${text(record.key)}, encoded ${text(encoded.key)}`],
|
|
1500
|
+
...compareBodies("value", {
|
|
1501
|
+
expected: record.value,
|
|
1502
|
+
encoded: encoded.value,
|
|
1503
|
+
comparison: options.compare ?? "bytes"
|
|
1504
|
+
}),
|
|
1505
|
+
...compareObjects("headers", {
|
|
1506
|
+
expected: textHeaders(record.headers ?? {}),
|
|
1507
|
+
encoded: textHeaders(encoded.headers),
|
|
1508
|
+
ignored: options.ignoreHeaders ?? []
|
|
1509
|
+
}),
|
|
1510
|
+
...timestamp === void 0 || timestamp === record.timestamp ? [] : [`timestamp: expected ${record.timestamp}, encoded ${timestamp}`]
|
|
1511
|
+
];
|
|
1512
|
+
}
|
|
1513
|
+
function textHeaders(headers) {
|
|
1514
|
+
return Object.fromEntries(
|
|
1515
|
+
Object.entries(headers).map(([name, value]) => [
|
|
1516
|
+
name,
|
|
1517
|
+
toBuffer(Array.isArray(value) ? value.at(-1) : value).toString(
|
|
1518
|
+
"utf8"
|
|
1519
|
+
)
|
|
1520
|
+
])
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
function delivered(record) {
|
|
1524
|
+
return {
|
|
1525
|
+
topic: record.topic,
|
|
1526
|
+
partition: record.partition ?? 0,
|
|
1527
|
+
offset: record.offset ?? "0",
|
|
1528
|
+
key: record.key === null ? null : toBuffer(record.key),
|
|
1529
|
+
value: record.value === null ? null : toBuffer(record.value),
|
|
1530
|
+
headers: Object.fromEntries(
|
|
1531
|
+
Object.entries(record.headers ?? {}).map(([name, value]) => [name, toBuffer(value)])
|
|
1532
|
+
),
|
|
1533
|
+
...record.timestamp === void 0 ? {} : { timestamp: record.timestamp }
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
async function rabbitMqCodecMismatches(options, testCase) {
|
|
1537
|
+
const { message } = testCase;
|
|
1538
|
+
const encoding = testCase.only === "decode" ? { mismatches: [] } : await attempt("encode", () => options.encode(testCase.envelope));
|
|
1539
|
+
const encoded = encoding.result;
|
|
1540
|
+
const decoding = testCase.only === "encode" ? { mismatches: [] } : await attempt("decode", () => options.decode(delivered2(message)));
|
|
1541
|
+
return [
|
|
1542
|
+
...encoding.mismatches,
|
|
1543
|
+
...encoded === void 0 ? [] : compareMessages(options, { message, encoded }),
|
|
1544
|
+
...decoding.mismatches,
|
|
1545
|
+
...decoding.result === void 0 ? [] : compareDecoded(testCase.decoded ?? testCase.envelope, decoding.result)
|
|
1546
|
+
];
|
|
1547
|
+
}
|
|
1548
|
+
function rabbitMqCodecContract(name, options) {
|
|
1549
|
+
vitest.describe(`${name} writes and reads the captured RabbitMQ messages`, () => {
|
|
1550
|
+
vitest.it.each(options.cases.map((testCase) => [testCase.name, testCase]))(
|
|
1551
|
+
"%s",
|
|
1552
|
+
async (_name, testCase) => {
|
|
1553
|
+
vitest.expect(await rabbitMqCodecMismatches(options, testCase)).toEqual([]);
|
|
1554
|
+
}
|
|
1555
|
+
);
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
function compareMessages(options, {
|
|
1559
|
+
message,
|
|
1560
|
+
encoded
|
|
1561
|
+
}) {
|
|
1562
|
+
const { headers, ...properties } = encoded.options;
|
|
1563
|
+
return [
|
|
1564
|
+
...compareObjects("properties", {
|
|
1565
|
+
expected: message.properties ?? {},
|
|
1566
|
+
encoded: properties,
|
|
1567
|
+
ignored: options.ignoreProperties ?? []
|
|
1568
|
+
}),
|
|
1569
|
+
...compareObjects("headers", {
|
|
1570
|
+
expected: message.headers ?? {},
|
|
1571
|
+
encoded: headers ?? {},
|
|
1572
|
+
ignored: options.ignoreHeaders ?? []
|
|
1573
|
+
}),
|
|
1574
|
+
...compareBodies("body", {
|
|
1575
|
+
expected: message.body,
|
|
1576
|
+
encoded: encoded.content,
|
|
1577
|
+
comparison: options.compare ?? "bytes"
|
|
1578
|
+
})
|
|
1579
|
+
];
|
|
1580
|
+
}
|
|
1581
|
+
function delivered2(message) {
|
|
1582
|
+
return {
|
|
1583
|
+
content: toBuffer(message.body),
|
|
1584
|
+
fields: { routingKey: message.routingKey },
|
|
1585
|
+
properties: { ...message.properties, headers: { ...message.headers } }
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1420
1588
|
|
|
1421
1589
|
exports.FakeClock = FakeClock;
|
|
1422
1590
|
exports.InMemoryTransport = InMemoryTransport;
|
|
@@ -1424,5 +1592,9 @@ exports.at = at;
|
|
|
1424
1592
|
exports.createDelivery = createDelivery;
|
|
1425
1593
|
exports.createEnvelope = createEnvelope;
|
|
1426
1594
|
exports.eventStoreContract = eventStoreContract;
|
|
1595
|
+
exports.kafkaCodecContract = kafkaCodecContract;
|
|
1596
|
+
exports.kafkaCodecMismatches = kafkaCodecMismatches;
|
|
1597
|
+
exports.rabbitMqCodecContract = rabbitMqCodecContract;
|
|
1598
|
+
exports.rabbitMqCodecMismatches = rabbitMqCodecMismatches;
|
|
1427
1599
|
//# sourceMappingURL=index.cjs.map
|
|
1428
1600
|
//# sourceMappingURL=index.cjs.map
|