@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 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 attempt = 0; ; attempt += 1) {
1409
+ for (let attempt2 = 0; ; attempt2 += 1) {
1410
1410
  try {
1411
1411
  await handler(delivery.envelope, { destination: delivery.destination });
1412
- return attempt + 1;
1412
+ return attempt2 + 1;
1413
1413
  } catch (error) {
1414
- if (attempt >= delivery.maxRedeliveries) {
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