@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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/{src-ChUwq33M.cjs → src-CfXXlBaO.cjs} +1116 -4
- package/dist/{src-DWkKYD4d.js → src-GWuAOela.js} +1116 -4
- package/dist/test-runner.cjs +1 -1
- package/dist/test-runner.js +1 -1
- package/package.json +2 -2
- package/src/index.ts +1502 -5
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
|
-
|
|
4413
|
-
|
|
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
|
}
|