@durable-streams/server-conformance-tests 0.1.6 → 0.1.8

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/src/index.ts CHANGED
@@ -1369,6 +1369,362 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1369
1369
  expect(text1).toBe(`hello world`)
1370
1370
  })
1371
1371
 
1372
+ test(`should accept offset=now as sentinel for current tail position`, async () => {
1373
+ const streamPath = `/v1/stream/offset-now-sentinel-test-${Date.now()}`
1374
+
1375
+ // Create stream with data
1376
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1377
+ method: `PUT`,
1378
+ headers: { "Content-Type": `text/plain` },
1379
+ body: `historical data`,
1380
+ })
1381
+
1382
+ // Using offset=now should return empty body with tail offset
1383
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1384
+ method: `GET`,
1385
+ })
1386
+
1387
+ expect(response.status).toBe(200)
1388
+ const text = await response.text()
1389
+ expect(text).toBe(``)
1390
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1391
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
1392
+ })
1393
+
1394
+ test(`should return correct tail offset for offset=now`, async () => {
1395
+ const streamPath = `/v1/stream/offset-now-tail-test-${Date.now()}`
1396
+
1397
+ // Create stream with data
1398
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1399
+ method: `PUT`,
1400
+ headers: { "Content-Type": `text/plain` },
1401
+ body: `initial data`,
1402
+ })
1403
+
1404
+ // Get the tail offset via normal read
1405
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1406
+ method: `GET`,
1407
+ })
1408
+ const tailOffset = readResponse.headers.get(STREAM_OFFSET_HEADER)
1409
+ expect(tailOffset).toBeDefined()
1410
+
1411
+ // offset=now should return the same tail offset
1412
+ const nowResponse = await fetch(
1413
+ `${getBaseUrl()}${streamPath}?offset=now`,
1414
+ {
1415
+ method: `GET`,
1416
+ }
1417
+ )
1418
+ expect(nowResponse.headers.get(STREAM_OFFSET_HEADER)).toBe(tailOffset)
1419
+ })
1420
+
1421
+ test(`should be able to resume from offset=now result`, async () => {
1422
+ const streamPath = `/v1/stream/offset-now-resume-test-${Date.now()}`
1423
+
1424
+ // Create stream with historical data
1425
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1426
+ method: `PUT`,
1427
+ headers: { "Content-Type": `text/plain` },
1428
+ body: `old data`,
1429
+ })
1430
+
1431
+ // Get tail position via offset=now
1432
+ const nowResponse = await fetch(
1433
+ `${getBaseUrl()}${streamPath}?offset=now`,
1434
+ {
1435
+ method: `GET`,
1436
+ }
1437
+ )
1438
+ const nowOffset = nowResponse.headers.get(STREAM_OFFSET_HEADER)
1439
+ expect(nowOffset).toBeDefined()
1440
+
1441
+ // Append new data
1442
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1443
+ method: `POST`,
1444
+ headers: { "Content-Type": `text/plain` },
1445
+ body: `new data`,
1446
+ })
1447
+
1448
+ // Resume from the offset we got - should only get new data
1449
+ const resumeResponse = await fetch(
1450
+ `${getBaseUrl()}${streamPath}?offset=${nowOffset}`,
1451
+ {
1452
+ method: `GET`,
1453
+ }
1454
+ )
1455
+ const resumeText = await resumeResponse.text()
1456
+ expect(resumeText).toBe(`new data`)
1457
+ })
1458
+
1459
+ test(`should work with offset=now on empty stream`, async () => {
1460
+ const streamPath = `/v1/stream/offset-now-empty-test-${Date.now()}`
1461
+
1462
+ // Create empty stream
1463
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1464
+ method: `PUT`,
1465
+ headers: { "Content-Type": `text/plain` },
1466
+ })
1467
+
1468
+ // offset=now on empty stream should still return empty with offset
1469
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1470
+ method: `GET`,
1471
+ })
1472
+
1473
+ expect(response.status).toBe(200)
1474
+ const text = await response.text()
1475
+ expect(text).toBe(``)
1476
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1477
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
1478
+ })
1479
+
1480
+ test(`should return empty JSON array for offset=now on JSON streams`, async () => {
1481
+ const streamPath = `/v1/stream/offset-now-json-body-test-${Date.now()}`
1482
+
1483
+ // Create JSON stream with data
1484
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1485
+ method: `PUT`,
1486
+ headers: { "Content-Type": `application/json` },
1487
+ body: `[{"event": "historical"}]`,
1488
+ })
1489
+
1490
+ // offset=now on JSON stream should return [] (empty array), not empty string
1491
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1492
+ method: `GET`,
1493
+ })
1494
+
1495
+ expect(response.status).toBe(200)
1496
+ expect(response.headers.get(`content-type`)).toBe(`application/json`)
1497
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1498
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
1499
+
1500
+ // Body MUST be [] for JSON streams (valid empty JSON array)
1501
+ const body = await response.text()
1502
+ expect(body).toBe(`[]`)
1503
+ })
1504
+
1505
+ test(`should return empty body for offset=now on non-JSON streams`, async () => {
1506
+ const streamPath = `/v1/stream/offset-now-text-body-test-${Date.now()}`
1507
+
1508
+ // Create text stream with data
1509
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1510
+ method: `PUT`,
1511
+ headers: { "Content-Type": `text/plain` },
1512
+ body: `historical data`,
1513
+ })
1514
+
1515
+ // offset=now on text stream should return empty body (0 bytes)
1516
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1517
+ method: `GET`,
1518
+ })
1519
+
1520
+ expect(response.status).toBe(200)
1521
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1522
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
1523
+
1524
+ // Body MUST be empty (0 bytes) for non-JSON streams
1525
+ const body = await response.text()
1526
+ expect(body).toBe(``)
1527
+ })
1528
+
1529
+ test(`should support offset=now with long-poll mode (waits for data)`, async () => {
1530
+ const streamPath = `/v1/stream/offset-now-longpoll-test-${Date.now()}`
1531
+
1532
+ // Create stream with data
1533
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1534
+ method: `PUT`,
1535
+ headers: { "Content-Type": `text/plain` },
1536
+ body: `existing data`,
1537
+ })
1538
+
1539
+ // Get tail offset first
1540
+ const readRes = await fetch(`${getBaseUrl()}${streamPath}`)
1541
+ const tailOffset = readRes.headers.get(STREAM_OFFSET_HEADER)
1542
+
1543
+ // offset=now with long-poll should immediately start waiting for new data
1544
+ // Since we don't append anything, it should timeout with 204
1545
+ const response = await fetch(
1546
+ `${getBaseUrl()}${streamPath}?offset=now&live=long-poll`,
1547
+ {
1548
+ method: `GET`,
1549
+ }
1550
+ )
1551
+
1552
+ // Should get 204 timeout (server waited for data but none arrived)
1553
+ expect(response.status).toBe(204)
1554
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1555
+ // Should return the tail offset
1556
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBe(tailOffset)
1557
+ })
1558
+
1559
+ test(`should receive data with offset=now long-poll when appended`, async () => {
1560
+ const streamPath = `/v1/stream/offset-now-longpoll-data-test-${Date.now()}`
1561
+
1562
+ // Create stream with historical data
1563
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1564
+ method: `PUT`,
1565
+ headers: { "Content-Type": `text/plain` },
1566
+ body: `historical`,
1567
+ })
1568
+
1569
+ // Start long-poll with offset=now (will wait for new data)
1570
+ const longPollPromise = fetch(
1571
+ `${getBaseUrl()}${streamPath}?offset=now&live=long-poll`,
1572
+ { method: `GET` }
1573
+ )
1574
+
1575
+ // Give the long-poll a moment to start waiting
1576
+ await new Promise((r) => setTimeout(r, 100))
1577
+
1578
+ // Append new data while long-poll is waiting
1579
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1580
+ method: `POST`,
1581
+ headers: { "Content-Type": `text/plain` },
1582
+ body: `new data`,
1583
+ })
1584
+
1585
+ // Long-poll should return with the new data (not historical)
1586
+ const response = await longPollPromise
1587
+ expect(response.status).toBe(200)
1588
+ const text = await response.text()
1589
+ expect(text).toBe(`new data`)
1590
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1591
+ })
1592
+
1593
+ test(`should support offset=now with SSE mode`, async () => {
1594
+ const streamPath = `/v1/stream/offset-now-sse-test-${Date.now()}`
1595
+
1596
+ // Create stream with data
1597
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1598
+ method: `PUT`,
1599
+ headers: { "Content-Type": `text/plain` },
1600
+ body: `existing data`,
1601
+ })
1602
+
1603
+ // Get tail offset first
1604
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1605
+ method: `GET`,
1606
+ })
1607
+ const tailOffset = readResponse.headers.get(STREAM_OFFSET_HEADER)
1608
+
1609
+ // offset=now with SSE should work and provide correct offset in control event
1610
+ const { response, received } = await fetchSSE(
1611
+ `${getBaseUrl()}${streamPath}?offset=now&live=sse`,
1612
+ { untilContent: `"upToDate"` }
1613
+ )
1614
+
1615
+ expect(response.status).toBe(200)
1616
+
1617
+ // Should have control event with upToDate:true and streamNextOffset
1618
+ const controlMatch = received.match(
1619
+ /event: control\s*\n\s*data: ({[^}]+})/
1620
+ )
1621
+ expect(controlMatch).toBeDefined()
1622
+ if (controlMatch && controlMatch[1]) {
1623
+ const controlData = JSON.parse(controlMatch[1])
1624
+ expect(controlData[`upToDate`]).toBe(true)
1625
+ expect(controlData[`streamNextOffset`]).toBe(tailOffset)
1626
+ }
1627
+ })
1628
+
1629
+ test(`should return 404 for offset=now on non-existent stream`, async () => {
1630
+ const streamPath = `/v1/stream/offset-now-404-test-${Date.now()}`
1631
+
1632
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1633
+ method: `GET`,
1634
+ })
1635
+
1636
+ expect(response.status).toBe(404)
1637
+ })
1638
+
1639
+ test(`should return 404 for offset=now with long-poll on non-existent stream`, async () => {
1640
+ const streamPath = `/v1/stream/offset-now-longpoll-404-test-${Date.now()}`
1641
+
1642
+ const response = await fetch(
1643
+ `${getBaseUrl()}${streamPath}?offset=now&live=long-poll`,
1644
+ { method: `GET` }
1645
+ )
1646
+
1647
+ expect(response.status).toBe(404)
1648
+ })
1649
+
1650
+ test(`should return 404 for offset=now with SSE on non-existent stream`, async () => {
1651
+ const streamPath = `/v1/stream/offset-now-sse-404-test-${Date.now()}`
1652
+
1653
+ const response = await fetch(
1654
+ `${getBaseUrl()}${streamPath}?offset=now&live=sse`,
1655
+ { method: `GET` }
1656
+ )
1657
+
1658
+ expect(response.status).toBe(404)
1659
+ })
1660
+
1661
+ test(`should support offset=now with long-poll on empty stream`, async () => {
1662
+ const streamPath = `/v1/stream/offset-now-empty-longpoll-test-${Date.now()}`
1663
+
1664
+ // Create empty stream
1665
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1666
+ method: `PUT`,
1667
+ headers: { "Content-Type": `text/plain` },
1668
+ })
1669
+
1670
+ // offset=now with long-poll on empty stream should timeout with 204
1671
+ const response = await fetch(
1672
+ `${getBaseUrl()}${streamPath}?offset=now&live=long-poll`,
1673
+ { method: `GET` }
1674
+ )
1675
+
1676
+ expect(response.status).toBe(204)
1677
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1678
+ // Should return a valid offset that can be used to resume
1679
+ const offset = response.headers.get(STREAM_OFFSET_HEADER)
1680
+ expect(offset).toBeDefined()
1681
+
1682
+ // Verify the offset works for future data
1683
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1684
+ method: `POST`,
1685
+ headers: { "Content-Type": `text/plain` },
1686
+ body: `first data`,
1687
+ })
1688
+
1689
+ const resumeResponse = await fetch(
1690
+ `${getBaseUrl()}${streamPath}?offset=${offset}`,
1691
+ { method: `GET` }
1692
+ )
1693
+ expect(resumeResponse.status).toBe(200)
1694
+ const resumeText = await resumeResponse.text()
1695
+ expect(resumeText).toBe(`first data`)
1696
+ })
1697
+
1698
+ test(`should support offset=now with SSE on empty stream`, async () => {
1699
+ const streamPath = `/v1/stream/offset-now-empty-sse-test-${Date.now()}`
1700
+
1701
+ // Create empty stream
1702
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1703
+ method: `PUT`,
1704
+ headers: { "Content-Type": `text/plain` },
1705
+ })
1706
+
1707
+ // offset=now with SSE on empty stream should return upToDate:true with valid offset
1708
+ const { response, received } = await fetchSSE(
1709
+ `${getBaseUrl()}${streamPath}?offset=now&live=sse`,
1710
+ { untilContent: `"upToDate"` }
1711
+ )
1712
+
1713
+ expect(response.status).toBe(200)
1714
+
1715
+ // Should have control event with upToDate:true and streamNextOffset
1716
+ const controlMatch = received.match(
1717
+ /event: control\s*\n\s*data: ({[^}]+})/
1718
+ )
1719
+ expect(controlMatch).toBeDefined()
1720
+ if (controlMatch && controlMatch[1]) {
1721
+ const controlData = JSON.parse(controlMatch[1])
1722
+ expect(controlData[`upToDate`]).toBe(true)
1723
+ // Should have a valid offset even on empty stream
1724
+ expect(controlData[`streamNextOffset`]).toBeDefined()
1725
+ }
1726
+ })
1727
+
1372
1728
  test(`should reject malformed offset (contains comma)`, async () => {
1373
1729
  const streamPath = `/v1/stream/offset-comma-test-${Date.now()}`
1374
1730
 
@@ -3982,7 +4338,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3982
4338
  ),
3983
4339
  { numRuns: 25 }
3984
4340
  )
3985
- })
4341
+ }, 15000)
3986
4342
 
3987
4343
  test(`read-your-writes: data is immediately visible after append`, async () => {
3988
4344
  await fc.assert(
@@ -4407,10 +4763,15 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4407
4763
  const responses = await Promise.all(operations)
4408
4764
 
4409
4765
  // All operations should succeed
4410
- // Writers (even indices) return 204, readers (odd indices) return 200
4766
+ // Writers (even indices) return 200 or 204, readers (odd indices) return 200
4411
4767
  responses.forEach((response, i) => {
4412
- const expectedStatus = i % 2 === 0 ? 204 : 200
4413
- expect(response.status).toBe(expectedStatus)
4768
+ if (i % 2 === 0) {
4769
+ // Writer - POST append can return 200 or 204
4770
+ expect([200, 204]).toContain(response.status)
4771
+ } else {
4772
+ // Reader - catch-up GET returns 200
4773
+ expect(response.status).toBe(200)
4774
+ }
4414
4775
  })
4415
4776
 
4416
4777
  // Final read should have all writes
@@ -4500,7 +4861,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4500
4861
  ),
4501
4862
  { numRuns: 15 }
4502
4863
  )
4503
- })
4864
+ }, 15000)
4504
4865
 
4505
4866
  test(`content hash changes with each append`, async () => {
4506
4867
  const streamPath = `/v1/stream/hash-changes-${Date.now()}-${Math.random().toString(36).slice(2)}`
@@ -4600,4 +4961,1140 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4600
4961
  })
4601
4962
  })
4602
4963
  })
4964
+
4965
+ // ============================================================================
4966
+ // Idempotent Producer Tests
4967
+ // ============================================================================
4968
+
4969
+ describe(`Idempotent Producer Operations`, () => {
4970
+ const PRODUCER_ID_HEADER = `Producer-Id`
4971
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`
4972
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`
4973
+ const PRODUCER_EXPECTED_SEQ_HEADER = `Producer-Expected-Seq`
4974
+ const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`
4975
+
4976
+ test(`should accept first append with producer headers (epoch=0, seq=0)`, async () => {
4977
+ const streamPath = `/v1/stream/producer-basic-${Date.now()}`
4978
+
4979
+ // Create stream
4980
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4981
+ method: `PUT`,
4982
+ headers: { "Content-Type": `text/plain` },
4983
+ })
4984
+
4985
+ // First append with producer headers
4986
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
4987
+ method: `POST`,
4988
+ headers: {
4989
+ "Content-Type": `text/plain`,
4990
+ [PRODUCER_ID_HEADER]: `test-producer`,
4991
+ [PRODUCER_EPOCH_HEADER]: `0`,
4992
+ [PRODUCER_SEQ_HEADER]: `0`,
4993
+ },
4994
+ body: `hello`,
4995
+ })
4996
+
4997
+ expect(response.status).toBe(200)
4998
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy()
4999
+ expect(response.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`0`)
5000
+ })
5001
+
5002
+ test(`should accept sequential producer sequences`, async () => {
5003
+ const streamPath = `/v1/stream/producer-seq-${Date.now()}`
5004
+
5005
+ // Create stream
5006
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5007
+ method: `PUT`,
5008
+ headers: { "Content-Type": `text/plain` },
5009
+ })
5010
+
5011
+ // Send seq=0
5012
+ const r0 = await fetch(`${getBaseUrl()}${streamPath}`, {
5013
+ method: `POST`,
5014
+ headers: {
5015
+ "Content-Type": `text/plain`,
5016
+ [PRODUCER_ID_HEADER]: `test-producer`,
5017
+ [PRODUCER_EPOCH_HEADER]: `0`,
5018
+ [PRODUCER_SEQ_HEADER]: `0`,
5019
+ },
5020
+ body: `msg0`,
5021
+ })
5022
+ expect(r0.status).toBe(200)
5023
+
5024
+ // Send seq=1
5025
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5026
+ method: `POST`,
5027
+ headers: {
5028
+ "Content-Type": `text/plain`,
5029
+ [PRODUCER_ID_HEADER]: `test-producer`,
5030
+ [PRODUCER_EPOCH_HEADER]: `0`,
5031
+ [PRODUCER_SEQ_HEADER]: `1`,
5032
+ },
5033
+ body: `msg1`,
5034
+ })
5035
+ expect(r1.status).toBe(200)
5036
+
5037
+ // Send seq=2
5038
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5039
+ method: `POST`,
5040
+ headers: {
5041
+ "Content-Type": `text/plain`,
5042
+ [PRODUCER_ID_HEADER]: `test-producer`,
5043
+ [PRODUCER_EPOCH_HEADER]: `0`,
5044
+ [PRODUCER_SEQ_HEADER]: `2`,
5045
+ },
5046
+ body: `msg2`,
5047
+ })
5048
+ expect(r2.status).toBe(200)
5049
+ })
5050
+
5051
+ test(`should return 204 for duplicate sequence (idempotent success)`, async () => {
5052
+ const streamPath = `/v1/stream/producer-dup-${Date.now()}`
5053
+
5054
+ // Create stream
5055
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5056
+ method: `PUT`,
5057
+ headers: { "Content-Type": `text/plain` },
5058
+ })
5059
+
5060
+ // First append
5061
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5062
+ method: `POST`,
5063
+ headers: {
5064
+ "Content-Type": `text/plain`,
5065
+ [PRODUCER_ID_HEADER]: `test-producer`,
5066
+ [PRODUCER_EPOCH_HEADER]: `0`,
5067
+ [PRODUCER_SEQ_HEADER]: `0`,
5068
+ },
5069
+ body: `hello`,
5070
+ })
5071
+ expect(r1.status).toBe(200)
5072
+
5073
+ // Duplicate append (same seq) - should return 204
5074
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5075
+ method: `POST`,
5076
+ headers: {
5077
+ "Content-Type": `text/plain`,
5078
+ [PRODUCER_ID_HEADER]: `test-producer`,
5079
+ [PRODUCER_EPOCH_HEADER]: `0`,
5080
+ [PRODUCER_SEQ_HEADER]: `0`,
5081
+ },
5082
+ body: `hello`,
5083
+ })
5084
+ expect(r2.status).toBe(204)
5085
+ })
5086
+
5087
+ test(`should accept epoch upgrade (new epoch starts at seq=0)`, async () => {
5088
+ const streamPath = `/v1/stream/producer-epoch-upgrade-${Date.now()}`
5089
+
5090
+ // Create stream
5091
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5092
+ method: `PUT`,
5093
+ headers: { "Content-Type": `text/plain` },
5094
+ })
5095
+
5096
+ // Establish epoch=0
5097
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5098
+ method: `POST`,
5099
+ headers: {
5100
+ "Content-Type": `text/plain`,
5101
+ [PRODUCER_ID_HEADER]: `test-producer`,
5102
+ [PRODUCER_EPOCH_HEADER]: `0`,
5103
+ [PRODUCER_SEQ_HEADER]: `0`,
5104
+ },
5105
+ body: `epoch0-msg0`,
5106
+ })
5107
+
5108
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5109
+ method: `POST`,
5110
+ headers: {
5111
+ "Content-Type": `text/plain`,
5112
+ [PRODUCER_ID_HEADER]: `test-producer`,
5113
+ [PRODUCER_EPOCH_HEADER]: `0`,
5114
+ [PRODUCER_SEQ_HEADER]: `1`,
5115
+ },
5116
+ body: `epoch0-msg1`,
5117
+ })
5118
+
5119
+ // Upgrade to epoch=1, seq=0
5120
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5121
+ method: `POST`,
5122
+ headers: {
5123
+ "Content-Type": `text/plain`,
5124
+ [PRODUCER_ID_HEADER]: `test-producer`,
5125
+ [PRODUCER_EPOCH_HEADER]: `1`,
5126
+ [PRODUCER_SEQ_HEADER]: `0`,
5127
+ },
5128
+ body: `epoch1-msg0`,
5129
+ })
5130
+ expect(r.status).toBe(200)
5131
+ expect(r.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`1`)
5132
+ })
5133
+
5134
+ test(`should reject stale epoch with 403 (zombie fencing)`, async () => {
5135
+ const streamPath = `/v1/stream/producer-stale-epoch-${Date.now()}`
5136
+
5137
+ // Create stream
5138
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5139
+ method: `PUT`,
5140
+ headers: { "Content-Type": `text/plain` },
5141
+ })
5142
+
5143
+ // Establish epoch=1
5144
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5145
+ method: `POST`,
5146
+ headers: {
5147
+ "Content-Type": `text/plain`,
5148
+ [PRODUCER_ID_HEADER]: `test-producer`,
5149
+ [PRODUCER_EPOCH_HEADER]: `1`,
5150
+ [PRODUCER_SEQ_HEADER]: `0`,
5151
+ },
5152
+ body: `msg`,
5153
+ })
5154
+
5155
+ // Try to write with epoch=0 (stale)
5156
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5157
+ method: `POST`,
5158
+ headers: {
5159
+ "Content-Type": `text/plain`,
5160
+ [PRODUCER_ID_HEADER]: `test-producer`,
5161
+ [PRODUCER_EPOCH_HEADER]: `0`,
5162
+ [PRODUCER_SEQ_HEADER]: `0`,
5163
+ },
5164
+ body: `zombie`,
5165
+ })
5166
+ expect(r.status).toBe(403)
5167
+ expect(r.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`1`)
5168
+ })
5169
+
5170
+ test(`should reject sequence gap with 409`, async () => {
5171
+ const streamPath = `/v1/stream/producer-seq-gap-${Date.now()}`
5172
+
5173
+ // Create stream
5174
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5175
+ method: `PUT`,
5176
+ headers: { "Content-Type": `text/plain` },
5177
+ })
5178
+
5179
+ // Send seq=0
5180
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5181
+ method: `POST`,
5182
+ headers: {
5183
+ "Content-Type": `text/plain`,
5184
+ [PRODUCER_ID_HEADER]: `test-producer`,
5185
+ [PRODUCER_EPOCH_HEADER]: `0`,
5186
+ [PRODUCER_SEQ_HEADER]: `0`,
5187
+ },
5188
+ body: `msg0`,
5189
+ })
5190
+
5191
+ // Skip seq=1, try to send seq=2 (gap)
5192
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5193
+ method: `POST`,
5194
+ headers: {
5195
+ "Content-Type": `text/plain`,
5196
+ [PRODUCER_ID_HEADER]: `test-producer`,
5197
+ [PRODUCER_EPOCH_HEADER]: `0`,
5198
+ [PRODUCER_SEQ_HEADER]: `2`,
5199
+ },
5200
+ body: `msg2`,
5201
+ })
5202
+ expect(r.status).toBe(409)
5203
+ expect(r.headers.get(PRODUCER_EXPECTED_SEQ_HEADER)).toBe(`1`)
5204
+ expect(r.headers.get(PRODUCER_RECEIVED_SEQ_HEADER)).toBe(`2`)
5205
+ })
5206
+
5207
+ test(`should reject epoch increase with seq != 0`, async () => {
5208
+ const streamPath = `/v1/stream/producer-epoch-bad-seq-${Date.now()}`
5209
+
5210
+ // Create stream
5211
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5212
+ method: `PUT`,
5213
+ headers: { "Content-Type": `text/plain` },
5214
+ })
5215
+
5216
+ // Establish epoch=0
5217
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5218
+ method: `POST`,
5219
+ headers: {
5220
+ "Content-Type": `text/plain`,
5221
+ [PRODUCER_ID_HEADER]: `test-producer`,
5222
+ [PRODUCER_EPOCH_HEADER]: `0`,
5223
+ [PRODUCER_SEQ_HEADER]: `0`,
5224
+ },
5225
+ body: `msg`,
5226
+ })
5227
+
5228
+ // Try epoch=1 with seq=5 (invalid - new epoch must start at seq=0)
5229
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5230
+ method: `POST`,
5231
+ headers: {
5232
+ "Content-Type": `text/plain`,
5233
+ [PRODUCER_ID_HEADER]: `test-producer`,
5234
+ [PRODUCER_EPOCH_HEADER]: `1`,
5235
+ [PRODUCER_SEQ_HEADER]: `5`,
5236
+ },
5237
+ body: `bad`,
5238
+ })
5239
+ expect(r.status).toBe(400)
5240
+ })
5241
+
5242
+ test(`should require all producer headers together`, async () => {
5243
+ const streamPath = `/v1/stream/producer-partial-headers-${Date.now()}`
5244
+
5245
+ // Create stream
5246
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5247
+ method: `PUT`,
5248
+ headers: { "Content-Type": `text/plain` },
5249
+ })
5250
+
5251
+ // Only Producer-Id
5252
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5253
+ method: `POST`,
5254
+ headers: {
5255
+ "Content-Type": `text/plain`,
5256
+ [PRODUCER_ID_HEADER]: `test-producer`,
5257
+ },
5258
+ body: `msg`,
5259
+ })
5260
+ expect(r1.status).toBe(400)
5261
+
5262
+ // Only Producer-Epoch
5263
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5264
+ method: `POST`,
5265
+ headers: {
5266
+ "Content-Type": `text/plain`,
5267
+ [PRODUCER_EPOCH_HEADER]: `0`,
5268
+ },
5269
+ body: `msg`,
5270
+ })
5271
+ expect(r2.status).toBe(400)
5272
+
5273
+ // Missing Producer-Seq
5274
+ const r3 = await fetch(`${getBaseUrl()}${streamPath}`, {
5275
+ method: `POST`,
5276
+ headers: {
5277
+ "Content-Type": `text/plain`,
5278
+ [PRODUCER_ID_HEADER]: `test-producer`,
5279
+ [PRODUCER_EPOCH_HEADER]: `0`,
5280
+ },
5281
+ body: `msg`,
5282
+ })
5283
+ expect(r3.status).toBe(400)
5284
+ })
5285
+
5286
+ test(`should reject invalid integer formats in producer headers`, async () => {
5287
+ const streamPath = `/v1/stream/producer-invalid-format-${Date.now()}`
5288
+
5289
+ // Create stream
5290
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5291
+ method: `PUT`,
5292
+ headers: { "Content-Type": `text/plain` },
5293
+ })
5294
+
5295
+ // Producer-Seq with trailing junk (e.g., "1abc")
5296
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5297
+ method: `POST`,
5298
+ headers: {
5299
+ "Content-Type": `text/plain`,
5300
+ [PRODUCER_ID_HEADER]: `test-producer`,
5301
+ [PRODUCER_EPOCH_HEADER]: `0`,
5302
+ [PRODUCER_SEQ_HEADER]: `1abc`,
5303
+ },
5304
+ body: `msg`,
5305
+ })
5306
+ expect(r1.status).toBe(400)
5307
+
5308
+ // Producer-Epoch with trailing junk
5309
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5310
+ method: `POST`,
5311
+ headers: {
5312
+ "Content-Type": `text/plain`,
5313
+ [PRODUCER_ID_HEADER]: `test-producer`,
5314
+ [PRODUCER_EPOCH_HEADER]: `0xyz`,
5315
+ [PRODUCER_SEQ_HEADER]: `0`,
5316
+ },
5317
+ body: `msg`,
5318
+ })
5319
+ expect(r2.status).toBe(400)
5320
+
5321
+ // Scientific notation should be rejected
5322
+ const r3 = await fetch(`${getBaseUrl()}${streamPath}`, {
5323
+ method: `POST`,
5324
+ headers: {
5325
+ "Content-Type": `text/plain`,
5326
+ [PRODUCER_ID_HEADER]: `test-producer`,
5327
+ [PRODUCER_EPOCH_HEADER]: `1e3`,
5328
+ [PRODUCER_SEQ_HEADER]: `0`,
5329
+ },
5330
+ body: `msg`,
5331
+ })
5332
+ expect(r3.status).toBe(400)
5333
+
5334
+ // Negative values should be rejected
5335
+ const r4 = await fetch(`${getBaseUrl()}${streamPath}`, {
5336
+ method: `POST`,
5337
+ headers: {
5338
+ "Content-Type": `text/plain`,
5339
+ [PRODUCER_ID_HEADER]: `test-producer`,
5340
+ [PRODUCER_EPOCH_HEADER]: `-1`,
5341
+ [PRODUCER_SEQ_HEADER]: `0`,
5342
+ },
5343
+ body: `msg`,
5344
+ })
5345
+ expect(r4.status).toBe(400)
5346
+
5347
+ // Valid integers should still work
5348
+ const r5 = await fetch(`${getBaseUrl()}${streamPath}`, {
5349
+ method: `POST`,
5350
+ headers: {
5351
+ "Content-Type": `text/plain`,
5352
+ [PRODUCER_ID_HEADER]: `test-producer`,
5353
+ [PRODUCER_EPOCH_HEADER]: `0`,
5354
+ [PRODUCER_SEQ_HEADER]: `0`,
5355
+ },
5356
+ body: `msg`,
5357
+ })
5358
+ expect(r5.status).toBe(200)
5359
+ })
5360
+
5361
+ test(`multiple producers should have independent state`, async () => {
5362
+ const streamPath = `/v1/stream/producer-multi-${Date.now()}`
5363
+
5364
+ // Create stream
5365
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5366
+ method: `PUT`,
5367
+ headers: { "Content-Type": `text/plain` },
5368
+ })
5369
+
5370
+ // Producer A: seq=0
5371
+ const rA0 = await fetch(`${getBaseUrl()}${streamPath}`, {
5372
+ method: `POST`,
5373
+ headers: {
5374
+ "Content-Type": `text/plain`,
5375
+ [PRODUCER_ID_HEADER]: `producer-A`,
5376
+ [PRODUCER_EPOCH_HEADER]: `0`,
5377
+ [PRODUCER_SEQ_HEADER]: `0`,
5378
+ },
5379
+ body: `A0`,
5380
+ })
5381
+ expect(rA0.status).toBe(200)
5382
+
5383
+ // Producer B: seq=0 (should be independent)
5384
+ const rB0 = await fetch(`${getBaseUrl()}${streamPath}`, {
5385
+ method: `POST`,
5386
+ headers: {
5387
+ "Content-Type": `text/plain`,
5388
+ [PRODUCER_ID_HEADER]: `producer-B`,
5389
+ [PRODUCER_EPOCH_HEADER]: `0`,
5390
+ [PRODUCER_SEQ_HEADER]: `0`,
5391
+ },
5392
+ body: `B0`,
5393
+ })
5394
+ expect(rB0.status).toBe(200)
5395
+
5396
+ // Producer A: seq=1
5397
+ const rA1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5398
+ method: `POST`,
5399
+ headers: {
5400
+ "Content-Type": `text/plain`,
5401
+ [PRODUCER_ID_HEADER]: `producer-A`,
5402
+ [PRODUCER_EPOCH_HEADER]: `0`,
5403
+ [PRODUCER_SEQ_HEADER]: `1`,
5404
+ },
5405
+ body: `A1`,
5406
+ })
5407
+ expect(rA1.status).toBe(200)
5408
+
5409
+ // Producer B: seq=1
5410
+ const rB1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5411
+ method: `POST`,
5412
+ headers: {
5413
+ "Content-Type": `text/plain`,
5414
+ [PRODUCER_ID_HEADER]: `producer-B`,
5415
+ [PRODUCER_EPOCH_HEADER]: `0`,
5416
+ [PRODUCER_SEQ_HEADER]: `1`,
5417
+ },
5418
+ body: `B1`,
5419
+ })
5420
+ expect(rB1.status).toBe(200)
5421
+ })
5422
+
5423
+ test(`duplicate of seq=0 should not corrupt state`, async () => {
5424
+ const streamPath = `/v1/stream/producer-dup-seq0-${Date.now()}`
5425
+
5426
+ // Create stream
5427
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5428
+ method: `PUT`,
5429
+ headers: { "Content-Type": `text/plain` },
5430
+ })
5431
+
5432
+ // First seq=0
5433
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5434
+ method: `POST`,
5435
+ headers: {
5436
+ "Content-Type": `text/plain`,
5437
+ [PRODUCER_ID_HEADER]: `test-producer`,
5438
+ [PRODUCER_EPOCH_HEADER]: `0`,
5439
+ [PRODUCER_SEQ_HEADER]: `0`,
5440
+ },
5441
+ body: `first`,
5442
+ })
5443
+ expect(r1.status).toBe(200)
5444
+
5445
+ // Retry seq=0 (simulating lost response)
5446
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5447
+ method: `POST`,
5448
+ headers: {
5449
+ "Content-Type": `text/plain`,
5450
+ [PRODUCER_ID_HEADER]: `test-producer`,
5451
+ [PRODUCER_EPOCH_HEADER]: `0`,
5452
+ [PRODUCER_SEQ_HEADER]: `0`,
5453
+ },
5454
+ body: `first`,
5455
+ })
5456
+ expect(r2.status).toBe(204) // Duplicate
5457
+
5458
+ // seq=1 should succeed (state not corrupted)
5459
+ const r3 = await fetch(`${getBaseUrl()}${streamPath}`, {
5460
+ method: `POST`,
5461
+ headers: {
5462
+ "Content-Type": `text/plain`,
5463
+ [PRODUCER_ID_HEADER]: `test-producer`,
5464
+ [PRODUCER_EPOCH_HEADER]: `0`,
5465
+ [PRODUCER_SEQ_HEADER]: `1`,
5466
+ },
5467
+ body: `second`,
5468
+ })
5469
+ expect(r3.status).toBe(200)
5470
+ })
5471
+
5472
+ test(`duplicate response should return highest accepted seq, not request seq`, async () => {
5473
+ const streamPath = `/v1/stream/producer-dup-highest-seq-${Date.now()}`
5474
+
5475
+ // Create stream
5476
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5477
+ method: `PUT`,
5478
+ headers: { "Content-Type": `text/plain` },
5479
+ })
5480
+
5481
+ // Send seq=0, 1, 2 successfully
5482
+ for (let i = 0; i < 3; i++) {
5483
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5484
+ method: `POST`,
5485
+ headers: {
5486
+ "Content-Type": `text/plain`,
5487
+ [PRODUCER_ID_HEADER]: `test-producer`,
5488
+ [PRODUCER_EPOCH_HEADER]: `0`,
5489
+ [PRODUCER_SEQ_HEADER]: `${i}`,
5490
+ },
5491
+ body: `msg-${i}`,
5492
+ })
5493
+ expect(r.status).toBe(200)
5494
+ expect(r.headers.get(PRODUCER_SEQ_HEADER)).toBe(`${i}`)
5495
+ }
5496
+
5497
+ // Now retry seq=1 (an older duplicate)
5498
+ // Per PROTOCOL.md: "the highest accepted sequence number for this (stream, producerId, epoch) tuple"
5499
+ // Should return 2 (highest accepted), not 1 (the request seq)
5500
+ const dupResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
5501
+ method: `POST`,
5502
+ headers: {
5503
+ "Content-Type": `text/plain`,
5504
+ [PRODUCER_ID_HEADER]: `test-producer`,
5505
+ [PRODUCER_EPOCH_HEADER]: `0`,
5506
+ [PRODUCER_SEQ_HEADER]: `1`,
5507
+ },
5508
+ body: `msg-1`,
5509
+ })
5510
+ expect(dupResponse.status).toBe(204)
5511
+ // The key assertion: should return highest (2), not request seq (1)
5512
+ expect(dupResponse.headers.get(PRODUCER_SEQ_HEADER)).toBe(`2`)
5513
+ })
5514
+
5515
+ test(`split-brain fencing scenario`, async () => {
5516
+ const streamPath = `/v1/stream/producer-split-brain-${Date.now()}`
5517
+
5518
+ // Create stream
5519
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5520
+ method: `PUT`,
5521
+ headers: { "Content-Type": `text/plain` },
5522
+ })
5523
+
5524
+ // Producer A (original): epoch=0, seq=0
5525
+ const rA0 = await fetch(`${getBaseUrl()}${streamPath}`, {
5526
+ method: `POST`,
5527
+ headers: {
5528
+ "Content-Type": `text/plain`,
5529
+ [PRODUCER_ID_HEADER]: `shared-producer`,
5530
+ [PRODUCER_EPOCH_HEADER]: `0`,
5531
+ [PRODUCER_SEQ_HEADER]: `0`,
5532
+ },
5533
+ body: `A0`,
5534
+ })
5535
+ expect(rA0.status).toBe(200)
5536
+
5537
+ // Producer B (new instance): claims with epoch=1
5538
+ const rB0 = await fetch(`${getBaseUrl()}${streamPath}`, {
5539
+ method: `POST`,
5540
+ headers: {
5541
+ "Content-Type": `text/plain`,
5542
+ [PRODUCER_ID_HEADER]: `shared-producer`,
5543
+ [PRODUCER_EPOCH_HEADER]: `1`,
5544
+ [PRODUCER_SEQ_HEADER]: `0`,
5545
+ },
5546
+ body: `B0`,
5547
+ })
5548
+ expect(rB0.status).toBe(200)
5549
+
5550
+ // Producer A (zombie): tries epoch=0, seq=1 - should be fenced
5551
+ const rA1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5552
+ method: `POST`,
5553
+ headers: {
5554
+ "Content-Type": `text/plain`,
5555
+ [PRODUCER_ID_HEADER]: `shared-producer`,
5556
+ [PRODUCER_EPOCH_HEADER]: `0`,
5557
+ [PRODUCER_SEQ_HEADER]: `1`,
5558
+ },
5559
+ body: `A1`,
5560
+ })
5561
+ expect(rA1.status).toBe(403)
5562
+ expect(rA1.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`1`)
5563
+ })
5564
+
5565
+ test(`epoch rollback should be rejected`, async () => {
5566
+ const streamPath = `/v1/stream/producer-epoch-rollback-${Date.now()}`
5567
+
5568
+ // Create stream
5569
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5570
+ method: `PUT`,
5571
+ headers: { "Content-Type": `text/plain` },
5572
+ })
5573
+
5574
+ // Establish epoch=2
5575
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5576
+ method: `POST`,
5577
+ headers: {
5578
+ "Content-Type": `text/plain`,
5579
+ [PRODUCER_ID_HEADER]: `test-producer`,
5580
+ [PRODUCER_EPOCH_HEADER]: `2`,
5581
+ [PRODUCER_SEQ_HEADER]: `0`,
5582
+ },
5583
+ body: `msg`,
5584
+ })
5585
+
5586
+ // Try epoch=1 (rollback)
5587
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5588
+ method: `POST`,
5589
+ headers: {
5590
+ "Content-Type": `text/plain`,
5591
+ [PRODUCER_ID_HEADER]: `test-producer`,
5592
+ [PRODUCER_EPOCH_HEADER]: `1`,
5593
+ [PRODUCER_SEQ_HEADER]: `0`,
5594
+ },
5595
+ body: `rollback`,
5596
+ })
5597
+ expect(r.status).toBe(403)
5598
+ })
5599
+
5600
+ test(`producer headers work with Stream-Seq header`, async () => {
5601
+ const streamPath = `/v1/stream/producer-with-stream-seq-${Date.now()}`
5602
+
5603
+ // Create stream
5604
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5605
+ method: `PUT`,
5606
+ headers: { "Content-Type": `text/plain` },
5607
+ })
5608
+
5609
+ // Append with both producer and Stream-Seq headers
5610
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5611
+ method: `POST`,
5612
+ headers: {
5613
+ "Content-Type": `text/plain`,
5614
+ [PRODUCER_ID_HEADER]: `test-producer`,
5615
+ [PRODUCER_EPOCH_HEADER]: `0`,
5616
+ [PRODUCER_SEQ_HEADER]: `0`,
5617
+ [STREAM_SEQ_HEADER]: `app-seq-001`,
5618
+ },
5619
+ body: `msg`,
5620
+ })
5621
+ expect(r.status).toBe(200)
5622
+ })
5623
+
5624
+ test(`producer duplicate should return 204 even with Stream-Seq header`, async () => {
5625
+ // This tests that producer dedupe is checked BEFORE Stream-Seq validation.
5626
+ // A retry with the same producer headers should be deduplicated at the
5627
+ // transport layer, returning 204, even if Stream-Seq would otherwise conflict.
5628
+ const streamPath = `/v1/stream/producer-dedupe-before-stream-seq-${Date.now()}`
5629
+
5630
+ // Create stream
5631
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5632
+ method: `PUT`,
5633
+ headers: { "Content-Type": `text/plain` },
5634
+ })
5635
+
5636
+ // First append with both producer and Stream-Seq headers
5637
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5638
+ method: `POST`,
5639
+ headers: {
5640
+ "Content-Type": `text/plain`,
5641
+ [PRODUCER_ID_HEADER]: `test-producer`,
5642
+ [PRODUCER_EPOCH_HEADER]: `0`,
5643
+ [PRODUCER_SEQ_HEADER]: `0`,
5644
+ [STREAM_SEQ_HEADER]: `app-seq-001`,
5645
+ },
5646
+ body: `msg`,
5647
+ })
5648
+ expect(r1.status).toBe(200)
5649
+
5650
+ // Retry the SAME append (same producer headers AND same Stream-Seq)
5651
+ // This should return 204 (duplicate) NOT 409 (Stream-Seq conflict)
5652
+ // because producer dedupe must be checked before Stream-Seq validation.
5653
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5654
+ method: `POST`,
5655
+ headers: {
5656
+ "Content-Type": `text/plain`,
5657
+ [PRODUCER_ID_HEADER]: `test-producer`,
5658
+ [PRODUCER_EPOCH_HEADER]: `0`,
5659
+ [PRODUCER_SEQ_HEADER]: `0`,
5660
+ [STREAM_SEQ_HEADER]: `app-seq-001`,
5661
+ },
5662
+ body: `msg`,
5663
+ })
5664
+ expect(r2.status).toBe(204)
5665
+ })
5666
+
5667
+ // ========================================================================
5668
+ // Data Integrity Tests - Read Back Verification
5669
+ // ========================================================================
5670
+
5671
+ test(`should store and read back data correctly`, async () => {
5672
+ const streamPath = `/v1/stream/producer-readback-${Date.now()}`
5673
+
5674
+ // Create stream
5675
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5676
+ method: `PUT`,
5677
+ headers: { "Content-Type": `text/plain` },
5678
+ })
5679
+
5680
+ // Append with producer headers
5681
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5682
+ method: `POST`,
5683
+ headers: {
5684
+ "Content-Type": `text/plain`,
5685
+ [PRODUCER_ID_HEADER]: `test-producer`,
5686
+ [PRODUCER_EPOCH_HEADER]: `0`,
5687
+ [PRODUCER_SEQ_HEADER]: `0`,
5688
+ },
5689
+ body: `hello world`,
5690
+ })
5691
+ expect(r.status).toBe(200)
5692
+
5693
+ // Read back and verify
5694
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5695
+ expect(readResponse.status).toBe(200)
5696
+ const content = await readResponse.text()
5697
+ expect(content).toBe(`hello world`)
5698
+ })
5699
+
5700
+ test(`should preserve order of sequential producer writes`, async () => {
5701
+ const streamPath = `/v1/stream/producer-order-${Date.now()}`
5702
+
5703
+ // Create stream
5704
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5705
+ method: `PUT`,
5706
+ headers: { "Content-Type": `text/plain` },
5707
+ })
5708
+
5709
+ // Append multiple messages in sequence
5710
+ for (let i = 0; i < 5; i++) {
5711
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5712
+ method: `POST`,
5713
+ headers: {
5714
+ "Content-Type": `text/plain`,
5715
+ [PRODUCER_ID_HEADER]: `test-producer`,
5716
+ [PRODUCER_EPOCH_HEADER]: `0`,
5717
+ [PRODUCER_SEQ_HEADER]: `${i}`,
5718
+ },
5719
+ body: `msg-${i}`,
5720
+ })
5721
+ expect(r.status).toBe(200)
5722
+ }
5723
+
5724
+ // Read back and verify order
5725
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5726
+ const content = await readResponse.text()
5727
+ expect(content).toBe(`msg-0msg-1msg-2msg-3msg-4`)
5728
+ })
5729
+
5730
+ test(`duplicate should not corrupt or duplicate data`, async () => {
5731
+ const streamPath = `/v1/stream/producer-dup-integrity-${Date.now()}`
5732
+
5733
+ // Create stream
5734
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5735
+ method: `PUT`,
5736
+ headers: { "Content-Type": `text/plain` },
5737
+ })
5738
+
5739
+ // First write
5740
+ const r1 = await fetch(`${getBaseUrl()}${streamPath}`, {
5741
+ method: `POST`,
5742
+ headers: {
5743
+ "Content-Type": `text/plain`,
5744
+ [PRODUCER_ID_HEADER]: `test-producer`,
5745
+ [PRODUCER_EPOCH_HEADER]: `0`,
5746
+ [PRODUCER_SEQ_HEADER]: `0`,
5747
+ },
5748
+ body: `first`,
5749
+ })
5750
+ expect(r1.status).toBe(200)
5751
+
5752
+ // Duplicate (retry)
5753
+ const r2 = await fetch(`${getBaseUrl()}${streamPath}`, {
5754
+ method: `POST`,
5755
+ headers: {
5756
+ "Content-Type": `text/plain`,
5757
+ [PRODUCER_ID_HEADER]: `test-producer`,
5758
+ [PRODUCER_EPOCH_HEADER]: `0`,
5759
+ [PRODUCER_SEQ_HEADER]: `0`,
5760
+ },
5761
+ body: `first`,
5762
+ })
5763
+ expect(r2.status).toBe(204)
5764
+
5765
+ // Continue with seq=1
5766
+ const r3 = await fetch(`${getBaseUrl()}${streamPath}`, {
5767
+ method: `POST`,
5768
+ headers: {
5769
+ "Content-Type": `text/plain`,
5770
+ [PRODUCER_ID_HEADER]: `test-producer`,
5771
+ [PRODUCER_EPOCH_HEADER]: `0`,
5772
+ [PRODUCER_SEQ_HEADER]: `1`,
5773
+ },
5774
+ body: `second`,
5775
+ })
5776
+ expect(r3.status).toBe(200)
5777
+
5778
+ // Read back - should have exactly "firstsecond", not "firstfirstsecond"
5779
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5780
+ const content = await readResponse.text()
5781
+ expect(content).toBe(`firstsecond`)
5782
+ })
5783
+
5784
+ test(`multiple producers should interleave correctly`, async () => {
5785
+ const streamPath = `/v1/stream/producer-interleave-${Date.now()}`
5786
+
5787
+ // Create stream
5788
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5789
+ method: `PUT`,
5790
+ headers: { "Content-Type": `text/plain` },
5791
+ })
5792
+
5793
+ // Interleave writes from two producers
5794
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5795
+ method: `POST`,
5796
+ headers: {
5797
+ "Content-Type": `text/plain`,
5798
+ [PRODUCER_ID_HEADER]: `producer-A`,
5799
+ [PRODUCER_EPOCH_HEADER]: `0`,
5800
+ [PRODUCER_SEQ_HEADER]: `0`,
5801
+ },
5802
+ body: `A0`,
5803
+ })
5804
+
5805
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5806
+ method: `POST`,
5807
+ headers: {
5808
+ "Content-Type": `text/plain`,
5809
+ [PRODUCER_ID_HEADER]: `producer-B`,
5810
+ [PRODUCER_EPOCH_HEADER]: `0`,
5811
+ [PRODUCER_SEQ_HEADER]: `0`,
5812
+ },
5813
+ body: `B0`,
5814
+ })
5815
+
5816
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5817
+ method: `POST`,
5818
+ headers: {
5819
+ "Content-Type": `text/plain`,
5820
+ [PRODUCER_ID_HEADER]: `producer-A`,
5821
+ [PRODUCER_EPOCH_HEADER]: `0`,
5822
+ [PRODUCER_SEQ_HEADER]: `1`,
5823
+ },
5824
+ body: `A1`,
5825
+ })
5826
+
5827
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5828
+ method: `POST`,
5829
+ headers: {
5830
+ "Content-Type": `text/plain`,
5831
+ [PRODUCER_ID_HEADER]: `producer-B`,
5832
+ [PRODUCER_EPOCH_HEADER]: `0`,
5833
+ [PRODUCER_SEQ_HEADER]: `1`,
5834
+ },
5835
+ body: `B1`,
5836
+ })
5837
+
5838
+ // Read back - should have all data in order of arrival
5839
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5840
+ const content = await readResponse.text()
5841
+ expect(content).toBe(`A0B0A1B1`)
5842
+ })
5843
+
5844
+ // ========================================================================
5845
+ // JSON Mode with Producer Headers
5846
+ // ========================================================================
5847
+
5848
+ test(`should store and read back JSON object correctly`, async () => {
5849
+ const streamPath = `/v1/stream/producer-json-obj-${Date.now()}`
5850
+
5851
+ // Create JSON stream
5852
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5853
+ method: `PUT`,
5854
+ headers: { "Content-Type": `application/json` },
5855
+ })
5856
+
5857
+ // Append JSON with producer headers
5858
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5859
+ method: `POST`,
5860
+ headers: {
5861
+ "Content-Type": `application/json`,
5862
+ [PRODUCER_ID_HEADER]: `test-producer`,
5863
+ [PRODUCER_EPOCH_HEADER]: `0`,
5864
+ [PRODUCER_SEQ_HEADER]: `0`,
5865
+ },
5866
+ body: JSON.stringify({ event: `test`, value: 42 }),
5867
+ })
5868
+ expect(r.status).toBe(200)
5869
+
5870
+ // Read back and verify
5871
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5872
+ const data = await readResponse.json()
5873
+ expect(data).toEqual([{ event: `test`, value: 42 }])
5874
+ })
5875
+
5876
+ test(`should preserve order of JSON appends with producer`, async () => {
5877
+ const streamPath = `/v1/stream/producer-json-order-${Date.now()}`
5878
+
5879
+ // Create JSON stream
5880
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5881
+ method: `PUT`,
5882
+ headers: { "Content-Type": `application/json` },
5883
+ })
5884
+
5885
+ // Append multiple JSON messages
5886
+ for (let i = 0; i < 5; i++) {
5887
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5888
+ method: `POST`,
5889
+ headers: {
5890
+ "Content-Type": `application/json`,
5891
+ [PRODUCER_ID_HEADER]: `test-producer`,
5892
+ [PRODUCER_EPOCH_HEADER]: `0`,
5893
+ [PRODUCER_SEQ_HEADER]: `${i}`,
5894
+ },
5895
+ body: JSON.stringify({ seq: i, data: `msg-${i}` }),
5896
+ })
5897
+ expect(r.status).toBe(200)
5898
+ }
5899
+
5900
+ // Read back and verify order
5901
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5902
+ const data = await readResponse.json()
5903
+ expect(data).toEqual([
5904
+ { seq: 0, data: `msg-0` },
5905
+ { seq: 1, data: `msg-1` },
5906
+ { seq: 2, data: `msg-2` },
5907
+ { seq: 3, data: `msg-3` },
5908
+ { seq: 4, data: `msg-4` },
5909
+ ])
5910
+ })
5911
+
5912
+ test(`JSON duplicate should not corrupt data`, async () => {
5913
+ const streamPath = `/v1/stream/producer-json-dup-${Date.now()}`
5914
+
5915
+ // Create JSON stream
5916
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5917
+ method: `PUT`,
5918
+ headers: { "Content-Type": `application/json` },
5919
+ })
5920
+
5921
+ // First write
5922
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5923
+ method: `POST`,
5924
+ headers: {
5925
+ "Content-Type": `application/json`,
5926
+ [PRODUCER_ID_HEADER]: `test-producer`,
5927
+ [PRODUCER_EPOCH_HEADER]: `0`,
5928
+ [PRODUCER_SEQ_HEADER]: `0`,
5929
+ },
5930
+ body: JSON.stringify({ id: 1 }),
5931
+ })
5932
+
5933
+ // Duplicate
5934
+ const dup = await fetch(`${getBaseUrl()}${streamPath}`, {
5935
+ method: `POST`,
5936
+ headers: {
5937
+ "Content-Type": `application/json`,
5938
+ [PRODUCER_ID_HEADER]: `test-producer`,
5939
+ [PRODUCER_EPOCH_HEADER]: `0`,
5940
+ [PRODUCER_SEQ_HEADER]: `0`,
5941
+ },
5942
+ body: JSON.stringify({ id: 1 }),
5943
+ })
5944
+ expect(dup.status).toBe(204)
5945
+
5946
+ // Continue
5947
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5948
+ method: `POST`,
5949
+ headers: {
5950
+ "Content-Type": `application/json`,
5951
+ [PRODUCER_ID_HEADER]: `test-producer`,
5952
+ [PRODUCER_EPOCH_HEADER]: `0`,
5953
+ [PRODUCER_SEQ_HEADER]: `1`,
5954
+ },
5955
+ body: JSON.stringify({ id: 2 }),
5956
+ })
5957
+
5958
+ // Read back - should have exactly [{id:1}, {id:2}]
5959
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
5960
+ const data = await readResponse.json()
5961
+ expect(data).toEqual([{ id: 1 }, { id: 2 }])
5962
+ })
5963
+
5964
+ test(`should reject invalid JSON with producer headers`, async () => {
5965
+ const streamPath = `/v1/stream/producer-json-invalid-${Date.now()}`
5966
+
5967
+ // Create JSON stream
5968
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5969
+ method: `PUT`,
5970
+ headers: { "Content-Type": `application/json` },
5971
+ })
5972
+
5973
+ // Try to append invalid JSON
5974
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5975
+ method: `POST`,
5976
+ headers: {
5977
+ "Content-Type": `application/json`,
5978
+ [PRODUCER_ID_HEADER]: `test-producer`,
5979
+ [PRODUCER_EPOCH_HEADER]: `0`,
5980
+ [PRODUCER_SEQ_HEADER]: `0`,
5981
+ },
5982
+ body: `{ invalid json }`,
5983
+ })
5984
+ expect(r.status).toBe(400)
5985
+ })
5986
+
5987
+ test(`should reject empty JSON array with producer headers`, async () => {
5988
+ const streamPath = `/v1/stream/producer-json-empty-${Date.now()}`
5989
+
5990
+ // Create JSON stream
5991
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5992
+ method: `PUT`,
5993
+ headers: { "Content-Type": `application/json` },
5994
+ })
5995
+
5996
+ // Try to append empty array
5997
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
5998
+ method: `POST`,
5999
+ headers: {
6000
+ "Content-Type": `application/json`,
6001
+ [PRODUCER_ID_HEADER]: `test-producer`,
6002
+ [PRODUCER_EPOCH_HEADER]: `0`,
6003
+ [PRODUCER_SEQ_HEADER]: `0`,
6004
+ },
6005
+ body: `[]`,
6006
+ })
6007
+ expect(r.status).toBe(400)
6008
+ })
6009
+
6010
+ // ========================================================================
6011
+ // Error Cases
6012
+ // ========================================================================
6013
+
6014
+ test(`should return 404 for non-existent stream`, async () => {
6015
+ const streamPath = `/v1/stream/producer-404-${Date.now()}`
6016
+
6017
+ // Try to append to non-existent stream
6018
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
6019
+ method: `POST`,
6020
+ headers: {
6021
+ "Content-Type": `text/plain`,
6022
+ [PRODUCER_ID_HEADER]: `test-producer`,
6023
+ [PRODUCER_EPOCH_HEADER]: `0`,
6024
+ [PRODUCER_SEQ_HEADER]: `0`,
6025
+ },
6026
+ body: `data`,
6027
+ })
6028
+ expect(r.status).toBe(404)
6029
+ })
6030
+
6031
+ test(`should return 409 for content-type mismatch`, async () => {
6032
+ const streamPath = `/v1/stream/producer-ct-mismatch-${Date.now()}`
6033
+
6034
+ // Create stream with text/plain
6035
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6036
+ method: `PUT`,
6037
+ headers: { "Content-Type": `text/plain` },
6038
+ })
6039
+
6040
+ // Try to append with application/json
6041
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
6042
+ method: `POST`,
6043
+ headers: {
6044
+ "Content-Type": `application/json`,
6045
+ [PRODUCER_ID_HEADER]: `test-producer`,
6046
+ [PRODUCER_EPOCH_HEADER]: `0`,
6047
+ [PRODUCER_SEQ_HEADER]: `0`,
6048
+ },
6049
+ body: JSON.stringify({ data: `test` }),
6050
+ })
6051
+ expect(r.status).toBe(409)
6052
+ })
6053
+
6054
+ test(`should return 400 for empty body`, async () => {
6055
+ const streamPath = `/v1/stream/producer-empty-body-${Date.now()}`
6056
+
6057
+ // Create stream
6058
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6059
+ method: `PUT`,
6060
+ headers: { "Content-Type": `text/plain` },
6061
+ })
6062
+
6063
+ // Try to append empty body
6064
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
6065
+ method: `POST`,
6066
+ headers: {
6067
+ "Content-Type": `text/plain`,
6068
+ [PRODUCER_ID_HEADER]: `test-producer`,
6069
+ [PRODUCER_EPOCH_HEADER]: `0`,
6070
+ [PRODUCER_SEQ_HEADER]: `0`,
6071
+ },
6072
+ body: ``,
6073
+ })
6074
+ expect(r.status).toBe(400)
6075
+ })
6076
+
6077
+ test(`should reject empty Producer-Id`, async () => {
6078
+ const streamPath = `/v1/stream/producer-empty-id-${Date.now()}`
6079
+
6080
+ // Create stream
6081
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6082
+ method: `PUT`,
6083
+ headers: { "Content-Type": `text/plain` },
6084
+ })
6085
+
6086
+ // Try with empty producer ID
6087
+ const r = await fetch(`${getBaseUrl()}${streamPath}`, {
6088
+ method: `POST`,
6089
+ headers: {
6090
+ "Content-Type": `text/plain`,
6091
+ [PRODUCER_ID_HEADER]: ``,
6092
+ [PRODUCER_EPOCH_HEADER]: `0`,
6093
+ [PRODUCER_SEQ_HEADER]: `0`,
6094
+ },
6095
+ body: `data`,
6096
+ })
6097
+ expect(r.status).toBe(400)
6098
+ })
6099
+ })
4603
6100
  }