@durable-streams/server-conformance-tests 0.1.5 → 0.1.7

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
@@ -110,7 +110,7 @@ function parseSSEEvents(
110
110
  sseText: string
111
111
  ): Array<{ type: string; data: string }> {
112
112
  const events: Array<{ type: string; data: string }> = []
113
- const normalized = sseText.replace(/\r\n/g, `\n`)
113
+ const normalized = sseText.replace(/\r\n/g, `\n`).replace(/\r/g, `\n`)
114
114
 
115
115
  // Split by double newlines (event boundaries)
116
116
  const eventBlocks = normalized.split(`\n\n`).filter((block) => block.trim())
@@ -479,7 +479,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
479
479
  method: `PUT`,
480
480
  headers: { "Content-Type": `text/plain` },
481
481
  })
482
- expect([200, 204]).toContain(secondResponse.status)
482
+ expect(secondResponse.status).toBe(200)
483
483
  })
484
484
 
485
485
  test(`should return 409 on PUT with different config`, async () => {
@@ -516,7 +516,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
516
516
  body: `hello world`,
517
517
  })
518
518
 
519
- expect([200, 204]).toContain(response.status)
519
+ expect(response.status).toBe(204)
520
520
  expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
521
521
  })
522
522
 
@@ -793,7 +793,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
793
793
  body: `second`,
794
794
  })
795
795
 
796
- expect([200, 204]).toContain(response.status)
796
+ expect(response.status).toBe(204)
797
797
  })
798
798
 
799
799
  test(`should reject duplicate seq values`, async () => {
@@ -829,6 +829,218 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
829
829
  })
830
830
  })
831
831
 
832
+ // ============================================================================
833
+ // Browser Security Headers (Protocol Section 10.7)
834
+ // ============================================================================
835
+
836
+ describe(`Browser Security Headers`, () => {
837
+ test(`should include X-Content-Type-Options: nosniff on GET responses`, async () => {
838
+ const streamPath = `/v1/stream/security-get-nosniff-${Date.now()}`
839
+
840
+ // Create stream with data
841
+ await fetch(`${getBaseUrl()}${streamPath}`, {
842
+ method: `PUT`,
843
+ headers: { "Content-Type": `text/plain` },
844
+ body: `test data`,
845
+ })
846
+
847
+ // Read data
848
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
849
+ method: `GET`,
850
+ })
851
+
852
+ expect(response.status).toBe(200)
853
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
854
+ })
855
+
856
+ test(`should include X-Content-Type-Options: nosniff on PUT responses`, async () => {
857
+ const streamPath = `/v1/stream/security-put-nosniff-${Date.now()}`
858
+
859
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
860
+ method: `PUT`,
861
+ headers: { "Content-Type": `text/plain` },
862
+ })
863
+
864
+ expect(response.status).toBe(201)
865
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
866
+ })
867
+
868
+ test(`should include X-Content-Type-Options: nosniff on POST responses`, async () => {
869
+ const streamPath = `/v1/stream/security-post-nosniff-${Date.now()}`
870
+
871
+ // Create stream
872
+ await fetch(`${getBaseUrl()}${streamPath}`, {
873
+ method: `PUT`,
874
+ headers: { "Content-Type": `text/plain` },
875
+ })
876
+
877
+ // Append data
878
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
879
+ method: `POST`,
880
+ headers: { "Content-Type": `text/plain` },
881
+ body: `data`,
882
+ })
883
+
884
+ expect([200, 204]).toContain(response.status)
885
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
886
+ })
887
+
888
+ test(`should include X-Content-Type-Options: nosniff on HEAD responses`, async () => {
889
+ const streamPath = `/v1/stream/security-head-nosniff-${Date.now()}`
890
+
891
+ // Create stream
892
+ await fetch(`${getBaseUrl()}${streamPath}`, {
893
+ method: `PUT`,
894
+ headers: { "Content-Type": `text/plain` },
895
+ })
896
+
897
+ // HEAD request
898
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
899
+ method: `HEAD`,
900
+ })
901
+
902
+ expect(response.status).toBe(200)
903
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
904
+ })
905
+
906
+ test(`should include Cross-Origin-Resource-Policy header on GET responses`, async () => {
907
+ const streamPath = `/v1/stream/security-corp-get-${Date.now()}`
908
+
909
+ // Create stream with data
910
+ await fetch(`${getBaseUrl()}${streamPath}`, {
911
+ method: `PUT`,
912
+ headers: { "Content-Type": `application/octet-stream` },
913
+ body: new Uint8Array([1, 2, 3, 4]),
914
+ })
915
+
916
+ // Read data
917
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
918
+ method: `GET`,
919
+ })
920
+
921
+ expect(response.status).toBe(200)
922
+ const corp = response.headers.get(`cross-origin-resource-policy`)
923
+ expect(corp).toBeDefined()
924
+ expect([`cross-origin`, `same-origin`, `same-site`]).toContain(corp)
925
+ })
926
+
927
+ test(`should include Cache-Control: no-store on HEAD responses`, async () => {
928
+ const streamPath = `/v1/stream/security-head-cache-${Date.now()}`
929
+
930
+ // Create stream
931
+ await fetch(`${getBaseUrl()}${streamPath}`, {
932
+ method: `PUT`,
933
+ headers: { "Content-Type": `text/plain` },
934
+ })
935
+
936
+ // HEAD request
937
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
938
+ method: `HEAD`,
939
+ })
940
+
941
+ expect(response.status).toBe(200)
942
+ const cacheControl = response.headers.get(`cache-control`)
943
+ expect(cacheControl).toBeDefined()
944
+ expect(cacheControl).toContain(`no-store`)
945
+ })
946
+
947
+ test(`should include X-Content-Type-Options: nosniff on SSE responses`, async () => {
948
+ const streamPath = `/v1/stream/security-sse-nosniff-${Date.now()}`
949
+
950
+ // Create stream with data
951
+ await fetch(`${getBaseUrl()}${streamPath}`, {
952
+ method: `PUT`,
953
+ headers: { "Content-Type": `application/json` },
954
+ body: JSON.stringify({ test: `data` }),
955
+ })
956
+
957
+ // Get offset
958
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
959
+ method: `HEAD`,
960
+ })
961
+ const offset = headResponse.headers.get(STREAM_OFFSET_HEADER) ?? `-1`
962
+
963
+ // SSE request with abort controller
964
+ const controller = new AbortController()
965
+ const timeoutId = setTimeout(() => controller.abort(), 500)
966
+
967
+ try {
968
+ const response = await fetch(
969
+ `${getBaseUrl()}${streamPath}?offset=${offset}&live=sse`,
970
+ {
971
+ method: `GET`,
972
+ signal: controller.signal,
973
+ }
974
+ )
975
+
976
+ expect(response.status).toBe(200)
977
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
978
+ } catch (e) {
979
+ // AbortError is expected
980
+ if (!(e instanceof Error && e.name === `AbortError`)) {
981
+ throw e
982
+ }
983
+ } finally {
984
+ clearTimeout(timeoutId)
985
+ }
986
+ })
987
+
988
+ test(`should include X-Content-Type-Options: nosniff on long-poll responses`, async () => {
989
+ const streamPath = `/v1/stream/security-longpoll-nosniff-${Date.now()}`
990
+
991
+ // Create stream with data
992
+ await fetch(`${getBaseUrl()}${streamPath}`, {
993
+ method: `PUT`,
994
+ headers: { "Content-Type": `text/plain` },
995
+ body: `initial data`,
996
+ })
997
+
998
+ // Get offset
999
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1000
+ method: `HEAD`,
1001
+ })
1002
+ const offset = headResponse.headers.get(STREAM_OFFSET_HEADER) ?? `-1`
1003
+
1004
+ // Long-poll request (will likely return 204 if no new data)
1005
+ const controller = new AbortController()
1006
+ const timeoutId = setTimeout(() => controller.abort(), 500)
1007
+
1008
+ try {
1009
+ const response = await fetch(
1010
+ `${getBaseUrl()}${streamPath}?offset=${offset}&live=long-poll`,
1011
+ {
1012
+ method: `GET`,
1013
+ signal: controller.signal,
1014
+ }
1015
+ )
1016
+
1017
+ // Either 200 (data) or 204 (timeout) - both should have nosniff
1018
+ expect([200, 204]).toContain(response.status)
1019
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
1020
+ } catch (e) {
1021
+ // AbortError is acceptable if request times out
1022
+ if (!(e instanceof Error && e.name === `AbortError`)) {
1023
+ throw e
1024
+ }
1025
+ } finally {
1026
+ clearTimeout(timeoutId)
1027
+ }
1028
+ })
1029
+
1030
+ test(`should include security headers on error responses`, async () => {
1031
+ const streamPath = `/v1/stream/security-error-headers-${Date.now()}`
1032
+
1033
+ // Try to read non-existent stream (404)
1034
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1035
+ method: `GET`,
1036
+ })
1037
+
1038
+ expect(response.status).toBe(404)
1039
+ // Security headers should be present even on error responses
1040
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
1041
+ })
1042
+ })
1043
+
832
1044
  // ============================================================================
833
1045
  // TTL and Expiry Validation
834
1046
  // ============================================================================
@@ -927,7 +1139,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
927
1139
  body: `test`,
928
1140
  })
929
1141
 
930
- expect([200, 204]).toContain(response.status)
1142
+ expect(response.status).toBe(204)
931
1143
  })
932
1144
 
933
1145
  test(`should allow idempotent create with different case content-type`, async () => {
@@ -945,7 +1157,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
945
1157
  method: `PUT`,
946
1158
  headers: { "Content-Type": `APPLICATION/JSON` },
947
1159
  })
948
- expect([200, 204]).toContain(response2.status)
1160
+ expect(response2.status).toBe(200)
949
1161
  })
950
1162
 
951
1163
  test(`should accept headers with different casing`, async () => {
@@ -967,7 +1179,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
967
1179
  body: `test`,
968
1180
  })
969
1181
 
970
- expect([200, 204]).toContain(response.status)
1182
+ expect(response.status).toBe(204)
971
1183
  })
972
1184
  })
973
1185
 
@@ -1011,7 +1223,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1011
1223
  body: `{"test": true}`,
1012
1224
  })
1013
1225
 
1014
- expect([200, 204]).toContain(response.status)
1226
+ expect(response.status).toBe(204)
1015
1227
  })
1016
1228
 
1017
1229
  test(`should return stream content-type on GET`, async () => {
@@ -1157,1264 +1369,1623 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1157
1369
  expect(text1).toBe(`hello world`)
1158
1370
  })
1159
1371
 
1160
- test(`should reject malformed offset (contains comma)`, async () => {
1161
- const streamPath = `/v1/stream/offset-comma-test-${Date.now()}`
1372
+ test(`should accept offset=now as sentinel for current tail position`, async () => {
1373
+ const streamPath = `/v1/stream/offset-now-sentinel-test-${Date.now()}`
1162
1374
 
1375
+ // Create stream with data
1163
1376
  await fetch(`${getBaseUrl()}${streamPath}`, {
1164
1377
  method: `PUT`,
1165
1378
  headers: { "Content-Type": `text/plain` },
1166
- body: `test`,
1379
+ body: `historical data`,
1167
1380
  })
1168
1381
 
1169
- const response = await fetch(`${getBaseUrl()}${streamPath}?offset=0,1`, {
1382
+ // Using offset=now should return empty body with tail offset
1383
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1170
1384
  method: `GET`,
1171
1385
  })
1172
1386
 
1173
- expect(response.status).toBe(400)
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()
1174
1392
  })
1175
1393
 
1176
- test(`should reject offset with spaces`, async () => {
1177
- const streamPath = `/v1/stream/offset-spaces-test-${Date.now()}`
1394
+ test(`should return correct tail offset for offset=now`, async () => {
1395
+ const streamPath = `/v1/stream/offset-now-tail-test-${Date.now()}`
1178
1396
 
1397
+ // Create stream with data
1179
1398
  await fetch(`${getBaseUrl()}${streamPath}`, {
1180
1399
  method: `PUT`,
1181
1400
  headers: { "Content-Type": `text/plain` },
1182
- body: `test`,
1401
+ body: `initial data`,
1183
1402
  })
1184
1403
 
1185
- const response = await fetch(`${getBaseUrl()}${streamPath}?offset=0 1`, {
1404
+ // Get the tail offset via normal read
1405
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1186
1406
  method: `GET`,
1187
1407
  })
1408
+ const tailOffset = readResponse.headers.get(STREAM_OFFSET_HEADER)
1409
+ expect(tailOffset).toBeDefined()
1188
1410
 
1189
- expect(response.status).toBe(400)
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)
1190
1419
  })
1191
1420
 
1192
- test(`should support resumable reads (no duplicate data)`, async () => {
1193
- const streamPath = `/v1/stream/resumable-test-${Date.now()}`
1421
+ test(`should be able to resume from offset=now result`, async () => {
1422
+ const streamPath = `/v1/stream/offset-now-resume-test-${Date.now()}`
1194
1423
 
1195
- // Create stream
1424
+ // Create stream with historical data
1196
1425
  await fetch(`${getBaseUrl()}${streamPath}`, {
1197
1426
  method: `PUT`,
1198
1427
  headers: { "Content-Type": `text/plain` },
1428
+ body: `old data`,
1199
1429
  })
1200
1430
 
1201
- // Append chunk 1
1202
- await fetch(`${getBaseUrl()}${streamPath}`, {
1203
- method: `POST`,
1204
- headers: { "Content-Type": `text/plain` },
1205
- body: `chunk1`,
1206
- })
1207
-
1208
- // Read chunk 1
1209
- const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
1210
- method: `GET`,
1211
- })
1212
- const text1 = await response1.text()
1213
- const offset1 = response1.headers.get(STREAM_OFFSET_HEADER)
1214
-
1215
- expect(text1).toBe(`chunk1`)
1216
- expect(offset1).toBeDefined()
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()
1217
1440
 
1218
- // Append chunk 2
1441
+ // Append new data
1219
1442
  await fetch(`${getBaseUrl()}${streamPath}`, {
1220
1443
  method: `POST`,
1221
1444
  headers: { "Content-Type": `text/plain` },
1222
- body: `chunk2`,
1445
+ body: `new data`,
1223
1446
  })
1224
1447
 
1225
- // Read from offset1 - should only get chunk2
1226
- const response2 = await fetch(
1227
- `${getBaseUrl()}${streamPath}?offset=${offset1}`,
1448
+ // Resume from the offset we got - should only get new data
1449
+ const resumeResponse = await fetch(
1450
+ `${getBaseUrl()}${streamPath}?offset=${nowOffset}`,
1228
1451
  {
1229
1452
  method: `GET`,
1230
1453
  }
1231
1454
  )
1232
- const text2 = await response2.text()
1233
-
1234
- expect(text2).toBe(`chunk2`)
1455
+ const resumeText = await resumeResponse.text()
1456
+ expect(resumeText).toBe(`new data`)
1235
1457
  })
1236
1458
 
1237
- test(`should return empty response when reading from tail offset`, async () => {
1238
- const streamPath = `/v1/stream/tail-read-test-${Date.now()}`
1459
+ test(`should work with offset=now on empty stream`, async () => {
1460
+ const streamPath = `/v1/stream/offset-now-empty-test-${Date.now()}`
1239
1461
 
1240
- // Create stream with data
1462
+ // Create empty stream
1241
1463
  await fetch(`${getBaseUrl()}${streamPath}`, {
1242
1464
  method: `PUT`,
1243
1465
  headers: { "Content-Type": `text/plain` },
1244
- body: `test`,
1245
1466
  })
1246
1467
 
1247
- // Read all data
1248
- const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
1468
+ // offset=now on empty stream should still return empty with offset
1469
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1249
1470
  method: `GET`,
1250
1471
  })
1251
- const tailOffset = response1.headers.get(STREAM_OFFSET_HEADER)
1252
-
1253
- // Read from tail offset - should return empty with up-to-date
1254
- const response2 = await fetch(
1255
- `${getBaseUrl()}${streamPath}?offset=${tailOffset}`,
1256
- {
1257
- method: `GET`,
1258
- }
1259
- )
1260
1472
 
1261
- expect(response2.status).toBe(200)
1262
- const text = await response2.text()
1473
+ expect(response.status).toBe(200)
1474
+ const text = await response.text()
1263
1475
  expect(text).toBe(``)
1264
- expect(response2.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1476
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1477
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
1265
1478
  })
1266
- })
1267
-
1268
- // ============================================================================
1269
- // Protocol Edge Cases
1270
- // ============================================================================
1271
1479
 
1272
- describe(`Protocol Edge Cases`, () => {
1273
- test(`should reject empty POST body with 400`, async () => {
1274
- const streamPath = `/v1/stream/empty-append-test-${Date.now()}`
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()}`
1275
1482
 
1276
- // Create stream
1483
+ // Create JSON stream with data
1277
1484
  await fetch(`${getBaseUrl()}${streamPath}`, {
1278
1485
  method: `PUT`,
1279
- headers: { "Content-Type": `text/plain` },
1486
+ headers: { "Content-Type": `application/json` },
1487
+ body: `[{"event": "historical"}]`,
1280
1488
  })
1281
1489
 
1282
- // Try to append empty body - should fail
1283
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1284
- method: `POST`,
1285
- headers: { "Content-Type": `text/plain` },
1286
- body: ``,
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`,
1287
1493
  })
1288
1494
 
1289
- expect(response.status).toBe(400)
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(`[]`)
1290
1503
  })
1291
1504
 
1292
- test(`should handle PUT with initial body correctly`, async () => {
1293
- const streamPath = `/v1/stream/put-initial-body-test-${Date.now()}`
1294
- const initialData = `initial stream content`
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()}`
1295
1507
 
1296
- // Create stream with initial content
1297
- const putResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1508
+ // Create text stream with data
1509
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1298
1510
  method: `PUT`,
1299
1511
  headers: { "Content-Type": `text/plain` },
1300
- body: initialData,
1512
+ body: `historical data`,
1301
1513
  })
1302
1514
 
1303
- expect(putResponse.status).toBe(201)
1304
- const nextOffset = putResponse.headers.get(STREAM_OFFSET_HEADER)
1305
- expect(nextOffset).toBeDefined()
1306
-
1307
- // Verify we can read the initial content
1308
- const getResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1515
+ // offset=now on text stream should return empty body (0 bytes)
1516
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1309
1517
  method: `GET`,
1310
1518
  })
1311
1519
 
1312
- const text = await getResponse.text()
1313
- expect(text).toBe(initialData)
1314
- expect(getResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
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(``)
1315
1527
  })
1316
1528
 
1317
- test(`should preserve data immutability by position`, async () => {
1318
- const streamPath = `/v1/stream/immutability-test-${Date.now()}`
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()}`
1319
1531
 
1320
- // Create and append first chunk
1532
+ // Create stream with data
1321
1533
  await fetch(`${getBaseUrl()}${streamPath}`, {
1322
1534
  method: `PUT`,
1323
1535
  headers: { "Content-Type": `text/plain` },
1324
- body: `chunk1`,
1325
- })
1326
-
1327
- // Read and save the offset after chunk1
1328
- const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
1329
- method: `GET`,
1330
- })
1331
- const text1 = await response1.text()
1332
- const offset1 = response1.headers.get(STREAM_OFFSET_HEADER)
1333
- expect(text1).toBe(`chunk1`)
1334
-
1335
- // Append more chunks
1336
- await fetch(`${getBaseUrl()}${streamPath}`, {
1337
- method: `POST`,
1338
- headers: { "Content-Type": `text/plain` },
1339
- body: `chunk2`,
1536
+ body: `existing data`,
1340
1537
  })
1341
1538
 
1342
- await fetch(`${getBaseUrl()}${streamPath}`, {
1343
- method: `POST`,
1344
- headers: { "Content-Type": `text/plain` },
1345
- body: `chunk3`,
1346
- })
1539
+ // Get tail offset first
1540
+ const readRes = await fetch(`${getBaseUrl()}${streamPath}`)
1541
+ const tailOffset = readRes.headers.get(STREAM_OFFSET_HEADER)
1347
1542
 
1348
- // Read from the saved offset - should still get chunk2 (position is immutable)
1349
- const response2 = await fetch(
1350
- `${getBaseUrl()}${streamPath}?offset=${offset1}`,
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`,
1351
1547
  {
1352
1548
  method: `GET`,
1353
1549
  }
1354
1550
  )
1355
- const text2 = await response2.text()
1356
- expect(text2).toBe(`chunk2chunk3`)
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)
1357
1557
  })
1358
1558
 
1359
- test(`should generate unique, monotonically increasing offsets`, async () => {
1360
- const streamPath = `/v1/stream/monotonic-offset-test-${Date.now()}`
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()}`
1361
1561
 
1362
- // Create stream
1562
+ // Create stream with historical data
1363
1563
  await fetch(`${getBaseUrl()}${streamPath}`, {
1364
1564
  method: `PUT`,
1365
1565
  headers: { "Content-Type": `text/plain` },
1566
+ body: `historical`,
1366
1567
  })
1367
1568
 
1368
- const offsets: Array<string> = []
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
+ )
1369
1574
 
1370
- // Append multiple chunks and collect offsets
1371
- for (let i = 0; i < 5; i++) {
1372
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1373
- method: `POST`,
1374
- headers: { "Content-Type": `text/plain` },
1375
- body: `chunk${i}`,
1376
- })
1575
+ // Give the long-poll a moment to start waiting
1576
+ await new Promise((r) => setTimeout(r, 100))
1377
1577
 
1378
- const offset = response.headers.get(STREAM_OFFSET_HEADER)
1379
- expect(offset).toBeDefined()
1380
- offsets.push(offset!)
1381
- }
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
+ })
1382
1584
 
1383
- // Verify offsets are unique and strictly increasing (lexicographically)
1384
- for (let i = 1; i < offsets.length; i++) {
1385
- expect(offsets[i]! > offsets[i - 1]!).toBe(true)
1386
- }
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`)
1387
1591
  })
1388
1592
 
1389
- test(`should reject empty offset parameter`, async () => {
1390
- const streamPath = `/v1/stream/empty-offset-test-${Date.now()}`
1593
+ test(`should support offset=now with SSE mode`, async () => {
1594
+ const streamPath = `/v1/stream/offset-now-sse-test-${Date.now()}`
1391
1595
 
1596
+ // Create stream with data
1392
1597
  await fetch(`${getBaseUrl()}${streamPath}`, {
1393
1598
  method: `PUT`,
1394
1599
  headers: { "Content-Type": `text/plain` },
1395
- body: `test`,
1600
+ body: `existing data`,
1396
1601
  })
1397
1602
 
1398
- const response = await fetch(`${getBaseUrl()}${streamPath}?offset=`, {
1603
+ // Get tail offset first
1604
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1399
1605
  method: `GET`,
1400
1606
  })
1607
+ const tailOffset = readResponse.headers.get(STREAM_OFFSET_HEADER)
1401
1608
 
1402
- expect(response.status).toBe(400)
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
+ }
1403
1627
  })
1404
1628
 
1405
- test(`should reject multiple offset parameters`, async () => {
1406
- const streamPath = `/v1/stream/multi-offset-test-${Date.now()}`
1629
+ test(`should return 404 for offset=now on non-existent stream`, async () => {
1630
+ const streamPath = `/v1/stream/offset-now-404-test-${Date.now()}`
1407
1631
 
1408
- await fetch(`${getBaseUrl()}${streamPath}`, {
1409
- method: `PUT`,
1410
- headers: { "Content-Type": `text/plain` },
1411
- body: `test`,
1632
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=now`, {
1633
+ method: `GET`,
1412
1634
  })
1413
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
+
1414
1642
  const response = await fetch(
1415
- `${getBaseUrl()}${streamPath}?offset=a&offset=b`,
1416
- {
1417
- method: `GET`,
1418
- }
1643
+ `${getBaseUrl()}${streamPath}?offset=now&live=long-poll`,
1644
+ { method: `GET` }
1419
1645
  )
1420
1646
 
1421
- expect(response.status).toBe(400)
1647
+ expect(response.status).toBe(404)
1422
1648
  })
1423
1649
 
1424
- test(`should enforce case-sensitive seq ordering`, async () => {
1425
- const streamPath = `/v1/stream/case-seq-test-${Date.now()}`
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()}`
1426
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
1427
1665
  await fetch(`${getBaseUrl()}${streamPath}`, {
1428
1666
  method: `PUT`,
1429
1667
  headers: { "Content-Type": `text/plain` },
1430
1668
  })
1431
1669
 
1432
- // Append with seq "a" (lowercase)
1433
- await fetch(`${getBaseUrl()}${streamPath}`, {
1434
- method: `POST`,
1435
- headers: {
1436
- "Content-Type": `text/plain`,
1437
- [STREAM_SEQ_HEADER]: `a`,
1438
- },
1439
- body: `first`,
1440
- })
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
+ )
1441
1675
 
1442
- // Try to append with seq "B" (uppercase) - should fail
1443
- // Lexicographically: "B" < "a" in byte order (uppercase comes before lowercase in ASCII)
1444
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
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}`, {
1445
1684
  method: `POST`,
1446
- headers: {
1447
- "Content-Type": `text/plain`,
1448
- [STREAM_SEQ_HEADER]: `B`,
1449
- },
1450
- body: `second`,
1685
+ headers: { "Content-Type": `text/plain` },
1686
+ body: `first data`,
1451
1687
  })
1452
1688
 
1453
- expect(response.status).toBe(409)
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`)
1454
1696
  })
1455
1697
 
1456
- test(`should handle binary data with integrity`, async () => {
1457
- const streamPath = `/v1/stream/binary-test-${Date.now()}`
1458
-
1459
- // Create binary stream with various byte values including 0x00 and 0xFF
1460
- const binaryData = new Uint8Array([
1461
- 0x00, 0x01, 0x02, 0x7f, 0x80, 0xfe, 0xff,
1462
- ])
1698
+ test(`should support offset=now with SSE on empty stream`, async () => {
1699
+ const streamPath = `/v1/stream/offset-now-empty-sse-test-${Date.now()}`
1463
1700
 
1701
+ // Create empty stream
1464
1702
  await fetch(`${getBaseUrl()}${streamPath}`, {
1465
1703
  method: `PUT`,
1466
- headers: { "Content-Type": `application/octet-stream` },
1467
- body: binaryData,
1704
+ headers: { "Content-Type": `text/plain` },
1468
1705
  })
1469
1706
 
1470
- // Read back and verify byte-for-byte
1471
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1472
- method: `GET`,
1473
- })
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
+ )
1474
1712
 
1475
- const buffer = await response.arrayBuffer()
1476
- const result = new Uint8Array(buffer)
1713
+ expect(response.status).toBe(200)
1477
1714
 
1478
- expect(result.length).toBe(binaryData.length)
1479
- for (let i = 0; i < binaryData.length; i++) {
1480
- expect(result[i]).toBe(binaryData[i])
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()
1481
1725
  }
1482
1726
  })
1483
1727
 
1484
- test(`should return Location header on 201`, async () => {
1485
- const streamPath = `/v1/stream/location-test-${Date.now()}`
1728
+ test(`should reject malformed offset (contains comma)`, async () => {
1729
+ const streamPath = `/v1/stream/offset-comma-test-${Date.now()}`
1486
1730
 
1487
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1731
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1488
1732
  method: `PUT`,
1489
1733
  headers: { "Content-Type": `text/plain` },
1734
+ body: `test`,
1490
1735
  })
1491
1736
 
1492
- expect(response.status).toBe(201)
1493
- const location = response.headers.get(`location`)
1494
- expect(location).toBeDefined()
1495
- expect(location).toBe(`${getBaseUrl()}${streamPath}`)
1737
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=0,1`, {
1738
+ method: `GET`,
1739
+ })
1740
+
1741
+ expect(response.status).toBe(400)
1496
1742
  })
1497
1743
 
1498
- test(`should reject missing Content-Type on POST`, async () => {
1499
- const streamPath = `/v1/stream/missing-ct-post-test-${Date.now()}`
1744
+ test(`should reject offset with spaces`, async () => {
1745
+ const streamPath = `/v1/stream/offset-spaces-test-${Date.now()}`
1500
1746
 
1501
- // Create stream
1502
1747
  await fetch(`${getBaseUrl()}${streamPath}`, {
1503
1748
  method: `PUT`,
1504
1749
  headers: { "Content-Type": `text/plain` },
1750
+ body: `test`,
1505
1751
  })
1506
1752
 
1507
- // Try to append without Content-Type - should fail
1508
- // Note: fetch will try to detect the Content-Type based on the body.
1509
- // Blob with an explicit empty type results in the header being omitted.
1510
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1511
- method: `POST`,
1512
- body: new Blob([`data`], { type: `` }),
1753
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=0 1`, {
1754
+ method: `GET`,
1513
1755
  })
1514
1756
 
1515
1757
  expect(response.status).toBe(400)
1516
1758
  })
1517
1759
 
1518
- test(`should accept PUT without Content-Type (use default)`, async () => {
1519
- const streamPath = `/v1/stream/no-ct-put-test-${Date.now()}`
1760
+ test(`should support resumable reads (no duplicate data)`, async () => {
1761
+ const streamPath = `/v1/stream/resumable-test-${Date.now()}`
1520
1762
 
1521
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1763
+ // Create stream
1764
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1522
1765
  method: `PUT`,
1766
+ headers: { "Content-Type": `text/plain` },
1523
1767
  })
1524
1768
 
1525
- expect([200, 201]).toContain(response.status)
1526
- const contentType = response.headers.get(`content-type`)
1527
- expect(contentType).toBeDefined()
1528
- })
1769
+ // Append chunk 1
1770
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1771
+ method: `POST`,
1772
+ headers: { "Content-Type": `text/plain` },
1773
+ body: `chunk1`,
1774
+ })
1529
1775
 
1530
- test(`should ignore unknown query parameters`, async () => {
1531
- const streamPath = `/v1/stream/unknown-param-test-${Date.now()}`
1776
+ // Read chunk 1
1777
+ const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
1778
+ method: `GET`,
1779
+ })
1780
+ const text1 = await response1.text()
1781
+ const offset1 = response1.headers.get(STREAM_OFFSET_HEADER)
1782
+
1783
+ expect(text1).toBe(`chunk1`)
1784
+ expect(offset1).toBeDefined()
1532
1785
 
1786
+ // Append chunk 2
1533
1787
  await fetch(`${getBaseUrl()}${streamPath}`, {
1534
- method: `PUT`,
1788
+ method: `POST`,
1535
1789
  headers: { "Content-Type": `text/plain` },
1536
- body: `test data`,
1790
+ body: `chunk2`,
1537
1791
  })
1538
1792
 
1539
- // Should work fine with unknown params (use -1 to start from beginning)
1540
- const response = await fetch(
1541
- `${getBaseUrl()}${streamPath}?offset=-1&foo=bar&baz=qux`,
1793
+ // Read from offset1 - should only get chunk2
1794
+ const response2 = await fetch(
1795
+ `${getBaseUrl()}${streamPath}?offset=${offset1}`,
1542
1796
  {
1543
1797
  method: `GET`,
1544
1798
  }
1545
1799
  )
1800
+ const text2 = await response2.text()
1546
1801
 
1547
- expect(response.status).toBe(200)
1548
- const text = await response.text()
1549
- expect(text).toBe(`test data`)
1802
+ expect(text2).toBe(`chunk2`)
1550
1803
  })
1551
- })
1552
-
1553
- // ============================================================================
1554
- // Long-Poll Edge Cases
1555
- // ============================================================================
1556
1804
 
1557
- describe(`Long-Poll Edge Cases`, () => {
1558
- test(`should require offset parameter for long-poll`, async () => {
1559
- const streamPath = `/v1/stream/longpoll-no-offset-test-${Date.now()}`
1805
+ test(`should return empty response when reading from tail offset`, async () => {
1806
+ const streamPath = `/v1/stream/tail-read-test-${Date.now()}`
1560
1807
 
1808
+ // Create stream with data
1561
1809
  await fetch(`${getBaseUrl()}${streamPath}`, {
1562
1810
  method: `PUT`,
1563
1811
  headers: { "Content-Type": `text/plain` },
1812
+ body: `test`,
1564
1813
  })
1565
1814
 
1566
- // Try long-poll without offset - protocol says offset MUST be provided
1567
- const response = await fetch(
1568
- `${getBaseUrl()}${streamPath}?live=long-poll`,
1815
+ // Read all data
1816
+ const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
1817
+ method: `GET`,
1818
+ })
1819
+ const tailOffset = response1.headers.get(STREAM_OFFSET_HEADER)
1820
+
1821
+ // Read from tail offset - should return empty with up-to-date
1822
+ const response2 = await fetch(
1823
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}`,
1569
1824
  {
1570
1825
  method: `GET`,
1571
1826
  }
1572
1827
  )
1573
1828
 
1574
- expect(response.status).toBe(400)
1829
+ expect(response2.status).toBe(200)
1830
+ const text = await response2.text()
1831
+ expect(text).toBe(``)
1832
+ expect(response2.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1575
1833
  })
1834
+ })
1576
1835
 
1577
- test(`should generate Stream-Cursor header on long-poll responses`, async () => {
1578
- const streamPath = `/v1/stream/longpoll-cursor-gen-test-${Date.now()}`
1836
+ // ============================================================================
1837
+ // Protocol Edge Cases
1838
+ // ============================================================================
1839
+
1840
+ describe(`Protocol Edge Cases`, () => {
1841
+ test(`should reject empty POST body with 400`, async () => {
1842
+ const streamPath = `/v1/stream/empty-append-test-${Date.now()}`
1579
1843
 
1844
+ // Create stream
1580
1845
  await fetch(`${getBaseUrl()}${streamPath}`, {
1581
1846
  method: `PUT`,
1582
1847
  headers: { "Content-Type": `text/plain` },
1583
- body: `test data`,
1584
1848
  })
1585
1849
 
1586
- // Long-poll request without cursor - server MUST generate one
1587
- const response = await fetch(
1588
- `${getBaseUrl()}${streamPath}?offset=-1&live=long-poll`,
1589
- {
1590
- method: `GET`,
1591
- }
1592
- )
1850
+ // Try to append empty body - should fail
1851
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1852
+ method: `POST`,
1853
+ headers: { "Content-Type": `text/plain` },
1854
+ body: ``,
1855
+ })
1593
1856
 
1594
- expect(response.status).toBe(200)
1857
+ expect(response.status).toBe(400)
1858
+ })
1595
1859
 
1596
- // Server MUST return a Stream-Cursor header
1597
- const cursor = response.headers.get(`Stream-Cursor`)
1598
- expect(cursor).toBeDefined()
1599
- expect(cursor).not.toBeNull()
1860
+ test(`should handle PUT with initial body correctly`, async () => {
1861
+ const streamPath = `/v1/stream/put-initial-body-test-${Date.now()}`
1862
+ const initialData = `initial stream content`
1600
1863
 
1601
- // Cursor must be a numeric string (interval number)
1602
- expect(/^\d+$/.test(cursor!)).toBe(true)
1864
+ // Create stream with initial content
1865
+ const putResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1866
+ method: `PUT`,
1867
+ headers: { "Content-Type": `text/plain` },
1868
+ body: initialData,
1869
+ })
1870
+
1871
+ expect(putResponse.status).toBe(201)
1872
+ const nextOffset = putResponse.headers.get(STREAM_OFFSET_HEADER)
1873
+ expect(nextOffset).toBeDefined()
1874
+
1875
+ // Verify we can read the initial content
1876
+ const getResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1877
+ method: `GET`,
1878
+ })
1879
+
1880
+ const text = await getResponse.text()
1881
+ expect(text).toBe(initialData)
1882
+ expect(getResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1603
1883
  })
1604
1884
 
1605
- test(`should echo cursor and handle collision with jitter`, async () => {
1606
- const streamPath = `/v1/stream/longpoll-cursor-collision-test-${Date.now()}`
1885
+ test(`should preserve data immutability by position`, async () => {
1886
+ const streamPath = `/v1/stream/immutability-test-${Date.now()}`
1607
1887
 
1888
+ // Create and append first chunk
1608
1889
  await fetch(`${getBaseUrl()}${streamPath}`, {
1609
1890
  method: `PUT`,
1610
1891
  headers: { "Content-Type": `text/plain` },
1611
- body: `test data`,
1892
+ body: `chunk1`,
1612
1893
  })
1613
1894
 
1614
- // First request to get current cursor
1615
- const response1 = await fetch(
1616
- `${getBaseUrl()}${streamPath}?offset=-1&live=long-poll`,
1617
- {
1618
- method: `GET`,
1619
- }
1620
- )
1895
+ // Read and save the offset after chunk1
1896
+ const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
1897
+ method: `GET`,
1898
+ })
1899
+ const text1 = await response1.text()
1900
+ const offset1 = response1.headers.get(STREAM_OFFSET_HEADER)
1901
+ expect(text1).toBe(`chunk1`)
1621
1902
 
1622
- expect(response1.status).toBe(200)
1623
- const cursor1 = response1.headers.get(`Stream-Cursor`)
1624
- expect(cursor1).toBeDefined()
1903
+ // Append more chunks
1904
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1905
+ method: `POST`,
1906
+ headers: { "Content-Type": `text/plain` },
1907
+ body: `chunk2`,
1908
+ })
1625
1909
 
1626
- // Immediate second request with same cursor - should get advanced cursor due to collision
1910
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1911
+ method: `POST`,
1912
+ headers: { "Content-Type": `text/plain` },
1913
+ body: `chunk3`,
1914
+ })
1915
+
1916
+ // Read from the saved offset - should still get chunk2 (position is immutable)
1627
1917
  const response2 = await fetch(
1628
- `${getBaseUrl()}${streamPath}?offset=-1&live=long-poll&cursor=${cursor1}`,
1918
+ `${getBaseUrl()}${streamPath}?offset=${offset1}`,
1629
1919
  {
1630
1920
  method: `GET`,
1631
1921
  }
1632
1922
  )
1923
+ const text2 = await response2.text()
1924
+ expect(text2).toBe(`chunk2chunk3`)
1925
+ })
1633
1926
 
1634
- expect(response2.status).toBe(200)
1635
- const cursor2 = response2.headers.get(`Stream-Cursor`)
1636
- expect(cursor2).toBeDefined()
1927
+ test(`should generate unique, monotonically increasing offsets`, async () => {
1928
+ const streamPath = `/v1/stream/monotonic-offset-test-${Date.now()}`
1637
1929
 
1638
- // The returned cursor MUST be strictly greater than the one we sent
1639
- // (monotonic progression prevents cache cycles)
1640
- expect(parseInt(cursor2!, 10)).toBeGreaterThan(parseInt(cursor1!, 10))
1641
- })
1930
+ // Create stream
1931
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1932
+ method: `PUT`,
1933
+ headers: { "Content-Type": `text/plain` },
1934
+ })
1642
1935
 
1643
- test(
1644
- `should return Stream-Cursor, Stream-Up-To-Date and Stream-Next-Offset on 204 timeout`,
1645
- async () => {
1646
- const streamPath = `/v1/stream/longpoll-204-headers-test-${Date.now()}`
1936
+ const offsets: Array<string> = []
1647
1937
 
1648
- await fetch(`${getBaseUrl()}${streamPath}`, {
1649
- method: `PUT`,
1938
+ // Append multiple chunks and collect offsets
1939
+ for (let i = 0; i < 5; i++) {
1940
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1941
+ method: `POST`,
1650
1942
  headers: { "Content-Type": `text/plain` },
1943
+ body: `chunk${i}`,
1651
1944
  })
1652
1945
 
1653
- // Get the current tail offset
1654
- const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1655
- method: `HEAD`,
1656
- })
1657
- const tailOffset = headResponse.headers.get(STREAM_OFFSET_HEADER)
1658
- expect(tailOffset).toBeDefined()
1659
-
1660
- // Long-poll at tail offset with a short timeout
1661
- // We use AbortController to limit wait time on our side
1662
- const controller = new AbortController()
1663
- const timeoutId = setTimeout(() => controller.abort(), 5000)
1664
-
1665
- try {
1666
- const response = await fetch(
1667
- `${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`,
1668
- {
1669
- method: `GET`,
1670
- signal: controller.signal,
1671
- }
1672
- )
1673
-
1674
- clearTimeout(timeoutId)
1675
-
1676
- // If we get a 204, verify headers
1677
- if (response.status === 204) {
1678
- expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
1679
- expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1680
-
1681
- // Server MUST return Stream-Cursor even on 204 timeout
1682
- const cursor = response.headers.get(`Stream-Cursor`)
1683
- expect(cursor).toBeDefined()
1684
- expect(/^\d+$/.test(cursor!)).toBe(true)
1685
- }
1686
- // If we get a 200 (data arrived somehow), that's also valid
1687
- expect([200, 204]).toContain(response.status)
1688
- } catch (e) {
1689
- clearTimeout(timeoutId)
1690
- // AbortError is expected if server timeout is longer than our 5s
1691
- if (e instanceof Error && e.name !== `AbortError`) {
1692
- throw e
1693
- }
1694
- // Test passes - server just has a longer timeout than our abort
1695
- }
1696
- },
1697
- getLongPollTestTimeoutMs()
1698
- )
1699
- })
1946
+ const offset = response.headers.get(STREAM_OFFSET_HEADER)
1947
+ expect(offset).toBeDefined()
1948
+ offsets.push(offset!)
1949
+ }
1700
1950
 
1701
- // ============================================================================
1702
- // TTL and Expiry Edge Cases
1703
- // ============================================================================
1951
+ // Verify offsets are unique and strictly increasing (lexicographically)
1952
+ for (let i = 1; i < offsets.length; i++) {
1953
+ expect(offsets[i]! > offsets[i - 1]!).toBe(true)
1954
+ }
1955
+ })
1704
1956
 
1705
- describe(`TTL and Expiry Edge Cases`, () => {
1706
- test(`should reject TTL with leading zeros`, async () => {
1707
- const streamPath = `/v1/stream/ttl-leading-zeros-test-${Date.now()}`
1957
+ test(`should reject empty offset parameter`, async () => {
1958
+ const streamPath = `/v1/stream/empty-offset-test-${Date.now()}`
1708
1959
 
1709
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1960
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1710
1961
  method: `PUT`,
1711
- headers: {
1712
- "Content-Type": `text/plain`,
1713
- "Stream-TTL": `00060`,
1714
- },
1962
+ headers: { "Content-Type": `text/plain` },
1963
+ body: `test`,
1964
+ })
1965
+
1966
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=`, {
1967
+ method: `GET`,
1715
1968
  })
1716
1969
 
1717
1970
  expect(response.status).toBe(400)
1718
1971
  })
1719
1972
 
1720
- test(`should reject TTL with plus sign`, async () => {
1721
- const streamPath = `/v1/stream/ttl-plus-test-${Date.now()}`
1973
+ test(`should reject multiple offset parameters`, async () => {
1974
+ const streamPath = `/v1/stream/multi-offset-test-${Date.now()}`
1722
1975
 
1723
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1976
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1724
1977
  method: `PUT`,
1725
- headers: {
1726
- "Content-Type": `text/plain`,
1727
- "Stream-TTL": `+60`,
1728
- },
1978
+ headers: { "Content-Type": `text/plain` },
1979
+ body: `test`,
1729
1980
  })
1730
1981
 
1982
+ const response = await fetch(
1983
+ `${getBaseUrl()}${streamPath}?offset=a&offset=b`,
1984
+ {
1985
+ method: `GET`,
1986
+ }
1987
+ )
1988
+
1731
1989
  expect(response.status).toBe(400)
1732
1990
  })
1733
1991
 
1734
- test(`should reject TTL with float value`, async () => {
1735
- const streamPath = `/v1/stream/ttl-float-test-${Date.now()}`
1992
+ test(`should enforce case-sensitive seq ordering`, async () => {
1993
+ const streamPath = `/v1/stream/case-seq-test-${Date.now()}`
1736
1994
 
1737
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1995
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1738
1996
  method: `PUT`,
1997
+ headers: { "Content-Type": `text/plain` },
1998
+ })
1999
+
2000
+ // Append with seq "a" (lowercase)
2001
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2002
+ method: `POST`,
1739
2003
  headers: {
1740
2004
  "Content-Type": `text/plain`,
1741
- "Stream-TTL": `60.5`,
2005
+ [STREAM_SEQ_HEADER]: `a`,
1742
2006
  },
2007
+ body: `first`,
1743
2008
  })
1744
2009
 
1745
- expect(response.status).toBe(400)
1746
- })
1747
-
1748
- test(`should reject TTL with scientific notation`, async () => {
1749
- const streamPath = `/v1/stream/ttl-scientific-test-${Date.now()}`
1750
-
2010
+ // Try to append with seq "B" (uppercase) - should fail
2011
+ // Lexicographically: "B" < "a" in byte order (uppercase comes before lowercase in ASCII)
1751
2012
  const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1752
- method: `PUT`,
2013
+ method: `POST`,
1753
2014
  headers: {
1754
2015
  "Content-Type": `text/plain`,
1755
- "Stream-TTL": `1e3`,
2016
+ [STREAM_SEQ_HEADER]: `B`,
1756
2017
  },
2018
+ body: `second`,
1757
2019
  })
1758
2020
 
1759
- expect(response.status).toBe(400)
2021
+ expect(response.status).toBe(409)
1760
2022
  })
1761
2023
 
1762
- test(`should reject invalid Expires-At timestamp`, async () => {
1763
- const streamPath = `/v1/stream/expires-invalid-test-${Date.now()}`
2024
+ test(`should handle binary data with integrity`, async () => {
2025
+ const streamPath = `/v1/stream/binary-test-${Date.now()}`
1764
2026
 
1765
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2027
+ // Create binary stream with various byte values including 0x00 and 0xFF
2028
+ const binaryData = new Uint8Array([
2029
+ 0x00, 0x01, 0x02, 0x7f, 0x80, 0xfe, 0xff,
2030
+ ])
2031
+
2032
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1766
2033
  method: `PUT`,
1767
- headers: {
1768
- "Content-Type": `text/plain`,
1769
- "Stream-Expires-At": `not-a-timestamp`,
1770
- },
2034
+ headers: { "Content-Type": `application/octet-stream` },
2035
+ body: binaryData,
1771
2036
  })
1772
2037
 
1773
- expect(response.status).toBe(400)
1774
- })
2038
+ // Read back and verify byte-for-byte
2039
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2040
+ method: `GET`,
2041
+ })
1775
2042
 
1776
- test(`should accept Expires-At with Z timezone`, async () => {
1777
- const streamPath = `/v1/stream/expires-z-test-${Date.now()}`
2043
+ const buffer = await response.arrayBuffer()
2044
+ const result = new Uint8Array(buffer)
1778
2045
 
1779
- const expiresAt = new Date(Date.now() + 3600000).toISOString()
2046
+ expect(result.length).toBe(binaryData.length)
2047
+ for (let i = 0; i < binaryData.length; i++) {
2048
+ expect(result[i]).toBe(binaryData[i])
2049
+ }
2050
+ })
2051
+
2052
+ test(`should return Location header on 201`, async () => {
2053
+ const streamPath = `/v1/stream/location-test-${Date.now()}`
1780
2054
 
1781
2055
  const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1782
2056
  method: `PUT`,
1783
- headers: {
1784
- "Content-Type": `text/plain`,
1785
- "Stream-Expires-At": expiresAt,
1786
- },
2057
+ headers: { "Content-Type": `text/plain` },
1787
2058
  })
1788
2059
 
1789
- expect([200, 201]).toContain(response.status)
2060
+ expect(response.status).toBe(201)
2061
+ const location = response.headers.get(`location`)
2062
+ expect(location).toBeDefined()
2063
+ // Check that Location contains the correct path (host may vary by server config)
2064
+ expect(location!.endsWith(streamPath)).toBe(true)
2065
+ // Verify it's a valid absolute URL
2066
+ expect(() => new URL(location!)).not.toThrow()
1790
2067
  })
1791
2068
 
1792
- test(`should accept Expires-At with timezone offset`, async () => {
1793
- const streamPath = `/v1/stream/expires-offset-test-${Date.now()}`
2069
+ test(`should reject missing Content-Type on POST`, async () => {
2070
+ const streamPath = `/v1/stream/missing-ct-post-test-${Date.now()}`
1794
2071
 
1795
- // RFC3339 with timezone offset
1796
- const date = new Date(Date.now() + 3600000)
1797
- const expiresAt = date.toISOString().replace(`Z`, `+00:00`)
2072
+ // Create stream
2073
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2074
+ method: `PUT`,
2075
+ headers: { "Content-Type": `text/plain` },
2076
+ })
1798
2077
 
2078
+ // Try to append without Content-Type - should fail
2079
+ // Note: fetch will try to detect the Content-Type based on the body.
2080
+ // Blob with an explicit empty type results in the header being omitted.
1799
2081
  const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1800
- method: `PUT`,
1801
- headers: {
1802
- "Content-Type": `text/plain`,
1803
- "Stream-Expires-At": expiresAt,
1804
- },
2082
+ method: `POST`,
2083
+ body: new Blob([`data`], { type: `` }),
1805
2084
  })
1806
2085
 
1807
- expect([200, 201]).toContain(response.status)
2086
+ expect(response.status).toBe(400)
1808
2087
  })
1809
2088
 
1810
- test(`should handle idempotent PUT with same TTL`, async () => {
1811
- const streamPath = `/v1/stream/ttl-idempotent-test-${Date.now()}`
2089
+ test(`should accept PUT without Content-Type (use default)`, async () => {
2090
+ const streamPath = `/v1/stream/no-ct-put-test-${Date.now()}`
1812
2091
 
1813
- // Create with TTL
1814
- const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
2092
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1815
2093
  method: `PUT`,
1816
- headers: {
1817
- "Content-Type": `text/plain`,
1818
- "Stream-TTL": `3600`,
1819
- },
1820
2094
  })
1821
- expect(response1.status).toBe(201)
1822
2095
 
1823
- // PUT again with same TTL - should be idempotent
1824
- const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
1825
- method: `PUT`,
1826
- headers: {
1827
- "Content-Type": `text/plain`,
1828
- "Stream-TTL": `3600`,
1829
- },
1830
- })
1831
- expect([200, 204]).toContain(response2.status)
2096
+ expect([200, 201]).toContain(response.status)
2097
+ const contentType = response.headers.get(`content-type`)
2098
+ expect(contentType).toBeDefined()
1832
2099
  })
1833
2100
 
1834
- test(`should reject idempotent PUT with different TTL`, async () => {
1835
- const streamPath = `/v1/stream/ttl-conflict-test-${Date.now()}`
2101
+ test(`should ignore unknown query parameters`, async () => {
2102
+ const streamPath = `/v1/stream/unknown-param-test-${Date.now()}`
1836
2103
 
1837
- // Create with TTL=3600
1838
2104
  await fetch(`${getBaseUrl()}${streamPath}`, {
1839
2105
  method: `PUT`,
1840
- headers: {
1841
- "Content-Type": `text/plain`,
1842
- "Stream-TTL": `3600`,
1843
- },
2106
+ headers: { "Content-Type": `text/plain` },
2107
+ body: `test data`,
1844
2108
  })
1845
2109
 
1846
- // PUT again with different TTL - should fail
1847
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1848
- method: `PUT`,
1849
- headers: {
1850
- "Content-Type": `text/plain`,
1851
- "Stream-TTL": `7200`,
1852
- },
1853
- })
2110
+ // Should work fine with unknown params (use -1 to start from beginning)
2111
+ const response = await fetch(
2112
+ `${getBaseUrl()}${streamPath}?offset=-1&foo=bar&baz=qux`,
2113
+ {
2114
+ method: `GET`,
2115
+ }
2116
+ )
1854
2117
 
1855
- expect(response.status).toBe(409)
2118
+ expect(response.status).toBe(200)
2119
+ const text = await response.text()
2120
+ expect(text).toBe(`test data`)
1856
2121
  })
1857
2122
  })
1858
2123
 
1859
2124
  // ============================================================================
1860
- // HEAD Metadata Edge Cases
2125
+ // Long-Poll Edge Cases
1861
2126
  // ============================================================================
1862
2127
 
1863
- describe(`HEAD Metadata Edge Cases`, () => {
1864
- test(`should return TTL metadata if configured`, async () => {
1865
- const streamPath = `/v1/stream/head-ttl-metadata-test-${Date.now()}`
2128
+ describe(`Long-Poll Edge Cases`, () => {
2129
+ test(`should require offset parameter for long-poll`, async () => {
2130
+ const streamPath = `/v1/stream/longpoll-no-offset-test-${Date.now()}`
1866
2131
 
1867
- // Create with TTL
1868
2132
  await fetch(`${getBaseUrl()}${streamPath}`, {
1869
2133
  method: `PUT`,
1870
- headers: {
1871
- "Content-Type": `text/plain`,
1872
- "Stream-TTL": `3600`,
1873
- },
2134
+ headers: { "Content-Type": `text/plain` },
1874
2135
  })
1875
2136
 
1876
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1877
- method: `HEAD`,
1878
- })
2137
+ // Try long-poll without offset - protocol says offset MUST be provided
2138
+ const response = await fetch(
2139
+ `${getBaseUrl()}${streamPath}?live=long-poll`,
2140
+ {
2141
+ method: `GET`,
2142
+ }
2143
+ )
1879
2144
 
1880
- // SHOULD return TTL metadata
1881
- const ttl = response.headers.get(`Stream-TTL`)
1882
- if (ttl) {
1883
- expect(parseInt(ttl)).toBeGreaterThan(0)
1884
- expect(parseInt(ttl)).toBeLessThanOrEqual(3600)
1885
- }
2145
+ expect(response.status).toBe(400)
1886
2146
  })
1887
2147
 
1888
- test(`should return Expires-At metadata if configured`, async () => {
1889
- const streamPath = `/v1/stream/head-expires-metadata-test-${Date.now()}`
1890
-
1891
- const expiresAt = new Date(Date.now() + 3600000).toISOString()
2148
+ test(`should generate Stream-Cursor header on long-poll responses`, async () => {
2149
+ const streamPath = `/v1/stream/longpoll-cursor-gen-test-${Date.now()}`
1892
2150
 
1893
- // Create with Expires-At
1894
2151
  await fetch(`${getBaseUrl()}${streamPath}`, {
1895
2152
  method: `PUT`,
1896
- headers: {
1897
- "Content-Type": `text/plain`,
1898
- "Stream-Expires-At": expiresAt,
1899
- },
1900
- })
1901
-
1902
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1903
- method: `HEAD`,
2153
+ headers: { "Content-Type": `text/plain` },
2154
+ body: `test data`,
1904
2155
  })
1905
2156
 
1906
- // SHOULD return Expires-At metadata
1907
- const expiresHeader = response.headers.get(`Stream-Expires-At`)
1908
- if (expiresHeader) {
1909
- expect(expiresHeader).toBeDefined()
1910
- }
1911
- })
1912
- })
1913
-
1914
- // ============================================================================
1915
- // TTL Expiration Behavior Tests
1916
- // ============================================================================
1917
-
1918
- describe(`TTL Expiration Behavior`, () => {
1919
- // Helper function to wait for a specified duration
1920
- const sleep = (ms: number) =>
1921
- new Promise((resolve) => setTimeout(resolve, ms))
1922
-
1923
- // Helper to generate unique stream paths for concurrent tests
1924
- const uniquePath = (prefix: string) =>
1925
- `/v1/stream/${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
1926
-
1927
- // Run tests concurrently to avoid 6x 1.5s wait time
1928
- test.concurrent(`should return 404 on HEAD after TTL expires`, async () => {
1929
- const streamPath = uniquePath(`ttl-expire-head`)
1930
-
1931
- // Create stream with 1 second TTL
1932
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1933
- method: `PUT`,
1934
- headers: {
1935
- "Content-Type": `text/plain`,
1936
- "Stream-TTL": `1`,
1937
- },
1938
- })
1939
- expect(createResponse.status).toBe(201)
2157
+ // Long-poll request without cursor - server MUST generate one
2158
+ const response = await fetch(
2159
+ `${getBaseUrl()}${streamPath}?offset=-1&live=long-poll`,
2160
+ {
2161
+ method: `GET`,
2162
+ }
2163
+ )
1940
2164
 
1941
- // Verify stream exists immediately
1942
- const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
1943
- method: `HEAD`,
1944
- })
1945
- expect(headBefore.status).toBe(200)
2165
+ expect(response.status).toBe(200)
1946
2166
 
1947
- // Wait for TTL to expire (1 second + buffer)
1948
- await sleep(1500)
2167
+ // Server MUST return a Stream-Cursor header
2168
+ const cursor = response.headers.get(`Stream-Cursor`)
2169
+ expect(cursor).toBeDefined()
2170
+ expect(cursor).not.toBeNull()
1949
2171
 
1950
- // Stream should no longer exist
1951
- const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
1952
- method: `HEAD`,
1953
- })
1954
- expect(headAfter.status).toBe(404)
2172
+ // Cursor must be a numeric string (interval number)
2173
+ expect(/^\d+$/.test(cursor!)).toBe(true)
1955
2174
  })
1956
2175
 
1957
- test.concurrent(`should return 404 on GET after TTL expires`, async () => {
1958
- const streamPath = uniquePath(`ttl-expire-get`)
2176
+ test(`should echo cursor and handle collision with jitter`, async () => {
2177
+ const streamPath = `/v1/stream/longpoll-cursor-collision-test-${Date.now()}`
1959
2178
 
1960
- // Create stream with 1 second TTL and some data
1961
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2179
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1962
2180
  method: `PUT`,
1963
- headers: {
1964
- "Content-Type": `text/plain`,
1965
- "Stream-TTL": `1`,
1966
- },
2181
+ headers: { "Content-Type": `text/plain` },
1967
2182
  body: `test data`,
1968
2183
  })
1969
- expect(createResponse.status).toBe(201)
1970
2184
 
1971
- // Verify stream is readable immediately
1972
- const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
1973
- method: `GET`,
1974
- })
1975
- expect(getBefore.status).toBe(200)
2185
+ // First request to get current cursor
2186
+ const response1 = await fetch(
2187
+ `${getBaseUrl()}${streamPath}?offset=-1&live=long-poll`,
2188
+ {
2189
+ method: `GET`,
2190
+ }
2191
+ )
1976
2192
 
1977
- // Wait for TTL to expire
1978
- await sleep(1500)
2193
+ expect(response1.status).toBe(200)
2194
+ const cursor1 = response1.headers.get(`Stream-Cursor`)
2195
+ expect(cursor1).toBeDefined()
1979
2196
 
1980
- // Stream should no longer exist
1981
- const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
1982
- method: `GET`,
1983
- })
1984
- expect(getAfter.status).toBe(404)
2197
+ // Immediate second request with same cursor - should get advanced cursor due to collision
2198
+ const response2 = await fetch(
2199
+ `${getBaseUrl()}${streamPath}?offset=-1&live=long-poll&cursor=${cursor1}`,
2200
+ {
2201
+ method: `GET`,
2202
+ }
2203
+ )
2204
+
2205
+ expect(response2.status).toBe(200)
2206
+ const cursor2 = response2.headers.get(`Stream-Cursor`)
2207
+ expect(cursor2).toBeDefined()
2208
+
2209
+ // The returned cursor MUST be strictly greater than the one we sent
2210
+ // (monotonic progression prevents cache cycles)
2211
+ expect(parseInt(cursor2!, 10)).toBeGreaterThan(parseInt(cursor1!, 10))
1985
2212
  })
1986
2213
 
1987
- test.concurrent(
1988
- `should return 404 on POST append after TTL expires`,
2214
+ test(
2215
+ `should return Stream-Cursor, Stream-Up-To-Date and Stream-Next-Offset on 204 timeout`,
1989
2216
  async () => {
1990
- const streamPath = uniquePath(`ttl-expire-post`)
2217
+ const streamPath = `/v1/stream/longpoll-204-headers-test-${Date.now()}`
1991
2218
 
1992
- // Create stream with 1 second TTL
1993
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2219
+ await fetch(`${getBaseUrl()}${streamPath}`, {
1994
2220
  method: `PUT`,
1995
- headers: {
1996
- "Content-Type": `text/plain`,
1997
- "Stream-TTL": `1`,
1998
- },
1999
- })
2000
- expect(createResponse.status).toBe(201)
2001
-
2002
- // Verify append works immediately
2003
- const postBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2004
- method: `POST`,
2005
2221
  headers: { "Content-Type": `text/plain` },
2006
- body: `appended data`,
2007
2222
  })
2008
- expect([200, 204]).toContain(postBefore.status)
2009
-
2010
- // Wait for TTL to expire
2011
- await sleep(1500)
2012
2223
 
2013
- // Append should fail - stream no longer exists
2014
- const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2015
- method: `POST`,
2016
- headers: { "Content-Type": `text/plain` },
2017
- body: `more data`,
2224
+ // Get the current tail offset
2225
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2226
+ method: `HEAD`,
2018
2227
  })
2019
- expect(postAfter.status).toBe(404)
2020
- }
2021
- )
2228
+ const tailOffset = headResponse.headers.get(STREAM_OFFSET_HEADER)
2229
+ expect(tailOffset).toBeDefined()
2022
2230
 
2023
- test.concurrent(
2024
- `should return 404 on HEAD after Expires-At passes`,
2025
- async () => {
2026
- const streamPath = uniquePath(`expires-at-head`)
2231
+ // Long-poll at tail offset with a short timeout
2232
+ // We use AbortController to limit wait time on our side
2233
+ const controller = new AbortController()
2234
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
2027
2235
 
2028
- // Create stream that expires in 1 second
2029
- const expiresAt = new Date(Date.now() + 1000).toISOString()
2030
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2031
- method: `PUT`,
2032
- headers: {
2033
- "Content-Type": `text/plain`,
2034
- "Stream-Expires-At": expiresAt,
2035
- },
2036
- })
2037
- expect(createResponse.status).toBe(201)
2236
+ try {
2237
+ const response = await fetch(
2238
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`,
2239
+ {
2240
+ method: `GET`,
2241
+ signal: controller.signal,
2242
+ }
2243
+ )
2038
2244
 
2039
- // Verify stream exists immediately
2040
- const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2041
- method: `HEAD`,
2042
- })
2043
- expect(headBefore.status).toBe(200)
2245
+ clearTimeout(timeoutId)
2044
2246
 
2045
- // Wait for expiry time to pass
2046
- await sleep(1500)
2247
+ // If we get a 204, verify headers
2248
+ if (response.status === 204) {
2249
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
2250
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
2047
2251
 
2048
- // Stream should no longer exist
2049
- const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2050
- method: `HEAD`,
2051
- })
2052
- expect(headAfter.status).toBe(404)
2053
- }
2252
+ // Server MUST return Stream-Cursor even on 204 timeout
2253
+ const cursor = response.headers.get(`Stream-Cursor`)
2254
+ expect(cursor).toBeDefined()
2255
+ expect(/^\d+$/.test(cursor!)).toBe(true)
2256
+ }
2257
+ // If we get a 200 (data arrived somehow), that's also valid
2258
+ expect([200, 204]).toContain(response.status)
2259
+ } catch (e) {
2260
+ clearTimeout(timeoutId)
2261
+ // AbortError is expected if server timeout is longer than our 5s
2262
+ if (e instanceof Error && e.name !== `AbortError`) {
2263
+ throw e
2264
+ }
2265
+ // Test passes - server just has a longer timeout than our abort
2266
+ }
2267
+ },
2268
+ getLongPollTestTimeoutMs()
2054
2269
  )
2270
+ })
2055
2271
 
2056
- test.concurrent(
2057
- `should return 404 on GET after Expires-At passes`,
2058
- async () => {
2059
- const streamPath = uniquePath(`expires-at-get`)
2272
+ // ============================================================================
2273
+ // TTL and Expiry Edge Cases
2274
+ // ============================================================================
2060
2275
 
2061
- // Create stream that expires in 1 second
2062
- const expiresAt = new Date(Date.now() + 1000).toISOString()
2063
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2064
- method: `PUT`,
2065
- headers: {
2066
- "Content-Type": `text/plain`,
2067
- "Stream-Expires-At": expiresAt,
2068
- },
2069
- body: `test data`,
2070
- })
2071
- expect(createResponse.status).toBe(201)
2276
+ describe(`TTL and Expiry Edge Cases`, () => {
2277
+ test(`should reject TTL with leading zeros`, async () => {
2278
+ const streamPath = `/v1/stream/ttl-leading-zeros-test-${Date.now()}`
2072
2279
 
2073
- // Verify stream is readable immediately
2074
- const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2075
- method: `GET`,
2076
- })
2077
- expect(getBefore.status).toBe(200)
2280
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2281
+ method: `PUT`,
2282
+ headers: {
2283
+ "Content-Type": `text/plain`,
2284
+ "Stream-TTL": `00060`,
2285
+ },
2286
+ })
2078
2287
 
2079
- // Wait for expiry time to pass
2080
- await sleep(1500)
2288
+ expect(response.status).toBe(400)
2289
+ })
2081
2290
 
2082
- // Stream should no longer exist
2083
- const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2084
- method: `GET`,
2085
- })
2086
- expect(getAfter.status).toBe(404)
2087
- }
2088
- )
2291
+ test(`should reject TTL with plus sign`, async () => {
2292
+ const streamPath = `/v1/stream/ttl-plus-test-${Date.now()}`
2089
2293
 
2090
- test.concurrent(
2091
- `should return 404 on POST append after Expires-At passes`,
2092
- async () => {
2093
- const streamPath = uniquePath(`expires-at-post`)
2294
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2295
+ method: `PUT`,
2296
+ headers: {
2297
+ "Content-Type": `text/plain`,
2298
+ "Stream-TTL": `+60`,
2299
+ },
2300
+ })
2094
2301
 
2095
- // Create stream that expires in 1 second
2096
- const expiresAt = new Date(Date.now() + 1000).toISOString()
2097
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2098
- method: `PUT`,
2099
- headers: {
2100
- "Content-Type": `text/plain`,
2101
- "Stream-Expires-At": expiresAt,
2102
- },
2103
- })
2104
- expect(createResponse.status).toBe(201)
2302
+ expect(response.status).toBe(400)
2303
+ })
2105
2304
 
2106
- // Verify append works immediately
2107
- const postBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2108
- method: `POST`,
2109
- headers: { "Content-Type": `text/plain` },
2110
- body: `appended data`,
2111
- })
2112
- expect([200, 204]).toContain(postBefore.status)
2305
+ test(`should reject TTL with float value`, async () => {
2306
+ const streamPath = `/v1/stream/ttl-float-test-${Date.now()}`
2113
2307
 
2114
- // Wait for expiry time to pass
2115
- await sleep(1500)
2308
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2309
+ method: `PUT`,
2310
+ headers: {
2311
+ "Content-Type": `text/plain`,
2312
+ "Stream-TTL": `60.5`,
2313
+ },
2314
+ })
2116
2315
 
2117
- // Append should fail - stream no longer exists
2118
- const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2119
- method: `POST`,
2120
- headers: { "Content-Type": `text/plain` },
2121
- body: `more data`,
2122
- })
2123
- expect(postAfter.status).toBe(404)
2124
- }
2125
- )
2316
+ expect(response.status).toBe(400)
2317
+ })
2126
2318
 
2127
- test.concurrent(
2128
- `should allow recreating stream after TTL expires`,
2129
- async () => {
2130
- const streamPath = uniquePath(`ttl-recreate`)
2319
+ test(`should reject TTL with scientific notation`, async () => {
2320
+ const streamPath = `/v1/stream/ttl-scientific-test-${Date.now()}`
2131
2321
 
2132
- // Create stream with 1 second TTL
2133
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2134
- method: `PUT`,
2135
- headers: {
2136
- "Content-Type": `text/plain`,
2137
- "Stream-TTL": `1`,
2138
- },
2139
- body: `original data`,
2140
- })
2141
- expect(createResponse.status).toBe(201)
2322
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2323
+ method: `PUT`,
2324
+ headers: {
2325
+ "Content-Type": `text/plain`,
2326
+ "Stream-TTL": `1e3`,
2327
+ },
2328
+ })
2142
2329
 
2143
- // Wait for TTL to expire
2144
- await sleep(1500)
2330
+ expect(response.status).toBe(400)
2331
+ })
2145
2332
 
2146
- // Recreate stream with different config - should succeed (201)
2147
- const recreateResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2148
- method: `PUT`,
2149
- headers: {
2150
- "Content-Type": `application/json`,
2151
- "Stream-TTL": `3600`,
2152
- },
2153
- body: `["new data"]`,
2154
- })
2155
- expect(recreateResponse.status).toBe(201)
2333
+ test(`should reject invalid Expires-At timestamp`, async () => {
2334
+ const streamPath = `/v1/stream/expires-invalid-test-${Date.now()}`
2156
2335
 
2157
- // Verify the new stream is accessible
2158
- const getResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2159
- method: `GET`,
2160
- })
2161
- expect(getResponse.status).toBe(200)
2162
- const body = await getResponse.text()
2163
- expect(body).toContain(`new data`)
2164
- }
2165
- )
2166
- })
2336
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2337
+ method: `PUT`,
2338
+ headers: {
2339
+ "Content-Type": `text/plain`,
2340
+ "Stream-Expires-At": `not-a-timestamp`,
2341
+ },
2342
+ })
2167
2343
 
2168
- // ============================================================================
2169
- // Caching and ETag Tests
2170
- // ============================================================================
2344
+ expect(response.status).toBe(400)
2345
+ })
2171
2346
 
2172
- describe(`Caching and ETag`, () => {
2173
- test(`should generate ETag on GET responses`, async () => {
2174
- const streamPath = `/v1/stream/etag-generate-test-${Date.now()}`
2347
+ test(`should accept Expires-At with Z timezone`, async () => {
2348
+ const streamPath = `/v1/stream/expires-z-test-${Date.now()}`
2175
2349
 
2176
- await fetch(`${getBaseUrl()}${streamPath}`, {
2177
- method: `PUT`,
2178
- headers: { "Content-Type": `text/plain` },
2179
- body: `test data`,
2180
- })
2350
+ const expiresAt = new Date(Date.now() + 3600000).toISOString()
2181
2351
 
2182
2352
  const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2183
- method: `GET`,
2353
+ method: `PUT`,
2354
+ headers: {
2355
+ "Content-Type": `text/plain`,
2356
+ "Stream-Expires-At": expiresAt,
2357
+ },
2184
2358
  })
2185
2359
 
2186
- expect(response.status).toBe(200)
2187
- const etag = response.headers.get(`etag`)
2188
- expect(etag).toBeDefined()
2189
- expect(etag!.length).toBeGreaterThan(0)
2360
+ expect([200, 201]).toContain(response.status)
2190
2361
  })
2191
2362
 
2192
- test(`should return 304 Not Modified for matching If-None-Match`, async () => {
2193
- const streamPath = `/v1/stream/etag-304-test-${Date.now()}`
2363
+ test(`should accept Expires-At with timezone offset`, async () => {
2364
+ const streamPath = `/v1/stream/expires-offset-test-${Date.now()}`
2194
2365
 
2195
- await fetch(`${getBaseUrl()}${streamPath}`, {
2366
+ // RFC3339 with timezone offset
2367
+ const date = new Date(Date.now() + 3600000)
2368
+ const expiresAt = date.toISOString().replace(`Z`, `+00:00`)
2369
+
2370
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2196
2371
  method: `PUT`,
2197
- headers: { "Content-Type": `text/plain` },
2198
- body: `test data`,
2372
+ headers: {
2373
+ "Content-Type": `text/plain`,
2374
+ "Stream-Expires-At": expiresAt,
2375
+ },
2199
2376
  })
2200
2377
 
2201
- // First request to get ETag
2378
+ expect([200, 201]).toContain(response.status)
2379
+ })
2380
+
2381
+ test(`should handle idempotent PUT with same TTL`, async () => {
2382
+ const streamPath = `/v1/stream/ttl-idempotent-test-${Date.now()}`
2383
+
2384
+ // Create with TTL
2202
2385
  const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
2203
- method: `GET`,
2386
+ method: `PUT`,
2387
+ headers: {
2388
+ "Content-Type": `text/plain`,
2389
+ "Stream-TTL": `3600`,
2390
+ },
2204
2391
  })
2392
+ expect(response1.status).toBe(201)
2205
2393
 
2206
- const etag = response1.headers.get(`etag`)
2207
- expect(etag).toBeDefined()
2208
-
2209
- // Second request with If-None-Match - MUST return 304
2394
+ // PUT again with same TTL - should be idempotent
2210
2395
  const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
2211
- method: `GET`,
2396
+ method: `PUT`,
2212
2397
  headers: {
2213
- "If-None-Match": etag!,
2398
+ "Content-Type": `text/plain`,
2399
+ "Stream-TTL": `3600`,
2214
2400
  },
2215
2401
  })
2216
-
2217
- expect(response2.status).toBe(304)
2218
- // 304 should have empty body
2219
- const text = await response2.text()
2220
- expect(text).toBe(``)
2402
+ expect(response2.status).toBe(200)
2221
2403
  })
2222
2404
 
2223
- test(`should return 200 for non-matching If-None-Match`, async () => {
2224
- const streamPath = `/v1/stream/etag-mismatch-test-${Date.now()}`
2405
+ test(`should reject idempotent PUT with different TTL`, async () => {
2406
+ const streamPath = `/v1/stream/ttl-conflict-test-${Date.now()}`
2225
2407
 
2408
+ // Create with TTL=3600
2226
2409
  await fetch(`${getBaseUrl()}${streamPath}`, {
2227
2410
  method: `PUT`,
2228
- headers: { "Content-Type": `text/plain` },
2229
- body: `test data`,
2411
+ headers: {
2412
+ "Content-Type": `text/plain`,
2413
+ "Stream-TTL": `3600`,
2414
+ },
2230
2415
  })
2231
2416
 
2232
- // Request with wrong ETag - should return 200 with data
2417
+ // PUT again with different TTL - should fail
2233
2418
  const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2234
- method: `GET`,
2419
+ method: `PUT`,
2235
2420
  headers: {
2236
- "If-None-Match": `"wrong-etag"`,
2421
+ "Content-Type": `text/plain`,
2422
+ "Stream-TTL": `7200`,
2237
2423
  },
2238
2424
  })
2239
2425
 
2240
- expect(response.status).toBe(200)
2241
- const text = await response.text()
2242
- expect(text).toBe(`test data`)
2426
+ expect(response.status).toBe(409)
2243
2427
  })
2428
+ })
2244
2429
 
2245
- test(`should return new ETag after data changes`, async () => {
2246
- const streamPath = `/v1/stream/etag-change-test-${Date.now()}`
2430
+ // ============================================================================
2431
+ // HEAD Metadata Edge Cases
2432
+ // ============================================================================
2433
+
2434
+ describe(`HEAD Metadata Edge Cases`, () => {
2435
+ test(`should return TTL metadata if configured`, async () => {
2436
+ const streamPath = `/v1/stream/head-ttl-metadata-test-${Date.now()}`
2247
2437
 
2438
+ // Create with TTL
2248
2439
  await fetch(`${getBaseUrl()}${streamPath}`, {
2249
2440
  method: `PUT`,
2250
- headers: { "Content-Type": `text/plain` },
2251
- body: `initial`,
2441
+ headers: {
2442
+ "Content-Type": `text/plain`,
2443
+ "Stream-TTL": `3600`,
2444
+ },
2252
2445
  })
2253
2446
 
2254
- // Get initial ETag
2255
- const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
2256
- method: `GET`,
2447
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2448
+ method: `HEAD`,
2257
2449
  })
2258
- const etag1 = response1.headers.get(`etag`)
2259
2450
 
2260
- // Append more data
2261
- await fetch(`${getBaseUrl()}${streamPath}`, {
2262
- method: `POST`,
2263
- headers: { "Content-Type": `text/plain` },
2264
- body: ` more`,
2265
- })
2451
+ // SHOULD return TTL metadata
2452
+ const ttl = response.headers.get(`Stream-TTL`)
2453
+ if (ttl) {
2454
+ expect(parseInt(ttl)).toBeGreaterThan(0)
2455
+ expect(parseInt(ttl)).toBeLessThanOrEqual(3600)
2456
+ }
2457
+ })
2266
2458
 
2267
- // Get new ETag
2268
- const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
2269
- method: `GET`,
2270
- })
2271
- const etag2 = response2.headers.get(`etag`)
2459
+ test(`should return Expires-At metadata if configured`, async () => {
2460
+ const streamPath = `/v1/stream/head-expires-metadata-test-${Date.now()}`
2272
2461
 
2273
- // ETags should be different
2274
- expect(etag1).not.toBe(etag2)
2462
+ const expiresAt = new Date(Date.now() + 3600000).toISOString()
2275
2463
 
2276
- // Old ETag should now return 200 (not 304)
2277
- const response3 = await fetch(`${getBaseUrl()}${streamPath}`, {
2278
- method: `GET`,
2464
+ // Create with Expires-At
2465
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2466
+ method: `PUT`,
2279
2467
  headers: {
2280
- "If-None-Match": etag1!,
2468
+ "Content-Type": `text/plain`,
2469
+ "Stream-Expires-At": expiresAt,
2281
2470
  },
2282
2471
  })
2283
- expect(response3.status).toBe(200)
2472
+
2473
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2474
+ method: `HEAD`,
2475
+ })
2476
+
2477
+ // SHOULD return Expires-At metadata
2478
+ const expiresHeader = response.headers.get(`Stream-Expires-At`)
2479
+ if (expiresHeader) {
2480
+ expect(expiresHeader).toBeDefined()
2481
+ }
2284
2482
  })
2285
2483
  })
2286
2484
 
2287
2485
  // ============================================================================
2288
- // Chunking and Large Payloads
2486
+ // TTL Expiration Behavior Tests
2289
2487
  // ============================================================================
2290
2488
 
2291
- describe(`Chunking and Large Payloads`, () => {
2292
- test(`should handle chunk-size pagination correctly`, async () => {
2293
- const streamPath = `/v1/stream/chunk-pagination-test-${Date.now()}`
2489
+ describe(`TTL Expiration Behavior`, () => {
2490
+ // Helper function to wait for a specified duration
2491
+ const sleep = (ms: number) =>
2492
+ new Promise((resolve) => setTimeout(resolve, ms))
2294
2493
 
2295
- await fetch(`${getBaseUrl()}${streamPath}`, {
2494
+ // Helper to generate unique stream paths for concurrent tests
2495
+ const uniquePath = (prefix: string) =>
2496
+ `/v1/stream/${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
2497
+
2498
+ // Run tests concurrently to avoid 6x 1.5s wait time
2499
+ test.concurrent(`should return 404 on HEAD after TTL expires`, async () => {
2500
+ const streamPath = uniquePath(`ttl-expire-head`)
2501
+
2502
+ // Create stream with 1 second TTL
2503
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2296
2504
  method: `PUT`,
2297
- headers: { "Content-Type": `application/octet-stream` },
2505
+ headers: {
2506
+ "Content-Type": `text/plain`,
2507
+ "Stream-TTL": `1`,
2508
+ },
2298
2509
  })
2510
+ expect(createResponse.status).toBe(201)
2299
2511
 
2300
- // Append a large amount of data (100KB)
2301
- const largeData = new Uint8Array(100 * 1024)
2302
- for (let i = 0; i < largeData.length; i++) {
2303
- largeData[i] = i % 256
2304
- }
2305
-
2306
- await fetch(`${getBaseUrl()}${streamPath}`, {
2307
- method: `POST`,
2308
- headers: { "Content-Type": `application/octet-stream` },
2309
- body: largeData,
2512
+ // Verify stream exists immediately
2513
+ const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2514
+ method: `HEAD`,
2310
2515
  })
2516
+ expect(headBefore.status).toBe(200)
2311
2517
 
2312
- // Read back using pagination
2313
- const accumulated: Array<number> = []
2314
- let currentOffset: string | null = null
2315
- let previousOffset: string | null = null
2316
- let iterations = 0
2317
- const maxIterations = 1000
2518
+ // Wait for TTL to expire (1 second + buffer)
2519
+ await sleep(1500)
2318
2520
 
2319
- while (iterations < maxIterations) {
2320
- iterations++
2521
+ // Stream should no longer exist
2522
+ const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2523
+ method: `HEAD`,
2524
+ })
2525
+ expect(headAfter.status).toBe(404)
2526
+ })
2321
2527
 
2322
- const url: string = currentOffset
2323
- ? `${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(currentOffset)}`
2324
- : `${getBaseUrl()}${streamPath}`
2528
+ test.concurrent(`should return 404 on GET after TTL expires`, async () => {
2529
+ const streamPath = uniquePath(`ttl-expire-get`)
2325
2530
 
2326
- const response: Response = await fetch(url, { method: `GET` })
2327
- expect(response.status).toBe(200)
2531
+ // Create stream with 1 second TTL and some data
2532
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2533
+ method: `PUT`,
2534
+ headers: {
2535
+ "Content-Type": `text/plain`,
2536
+ "Stream-TTL": `1`,
2537
+ },
2538
+ body: `test data`,
2539
+ })
2540
+ expect(createResponse.status).toBe(201)
2328
2541
 
2329
- const buffer = await response.arrayBuffer()
2330
- const data = new Uint8Array(buffer)
2542
+ // Verify stream is readable immediately
2543
+ const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2544
+ method: `GET`,
2545
+ })
2546
+ expect(getBefore.status).toBe(200)
2331
2547
 
2332
- if (data.length > 0) {
2333
- accumulated.push(...Array.from(data))
2334
- }
2548
+ // Wait for TTL to expire
2549
+ await sleep(1500)
2335
2550
 
2336
- const nextOffset: string | null =
2337
- response.headers.get(STREAM_OFFSET_HEADER)
2338
- const upToDate = response.headers.get(STREAM_UP_TO_DATE_HEADER)
2551
+ // Stream should no longer exist
2552
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2553
+ method: `GET`,
2554
+ })
2555
+ expect(getAfter.status).toBe(404)
2556
+ })
2339
2557
 
2340
- if (upToDate === `true` && data.length === 0) {
2341
- break
2342
- }
2558
+ test.concurrent(
2559
+ `should return 404 on POST append after TTL expires`,
2560
+ async () => {
2561
+ const streamPath = uniquePath(`ttl-expire-post`)
2343
2562
 
2344
- expect(nextOffset).toBeDefined()
2563
+ // Create stream with 1 second TTL
2564
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2565
+ method: `PUT`,
2566
+ headers: {
2567
+ "Content-Type": `text/plain`,
2568
+ "Stream-TTL": `1`,
2569
+ },
2570
+ })
2571
+ expect(createResponse.status).toBe(201)
2345
2572
 
2346
- // Verify offset progresses
2347
- if (nextOffset === currentOffset && data.length === 0) {
2348
- break
2349
- }
2573
+ // Verify append works immediately
2574
+ const postBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2575
+ method: `POST`,
2576
+ headers: { "Content-Type": `text/plain` },
2577
+ body: `appended data`,
2578
+ })
2579
+ expect(postBefore.status).toBe(204)
2350
2580
 
2351
- // Verify monotonic progression
2352
- if (previousOffset && nextOffset) {
2353
- expect(nextOffset >= previousOffset).toBe(true)
2354
- }
2581
+ // Wait for TTL to expire
2582
+ await sleep(1500)
2355
2583
 
2356
- previousOffset = currentOffset
2357
- currentOffset = nextOffset
2584
+ // Append should fail - stream no longer exists
2585
+ const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2586
+ method: `POST`,
2587
+ headers: { "Content-Type": `text/plain` },
2588
+ body: `more data`,
2589
+ })
2590
+ expect(postAfter.status).toBe(404)
2358
2591
  }
2592
+ )
2359
2593
 
2360
- // Verify we got all the data
2361
- const result = new Uint8Array(accumulated)
2362
- expect(result.length).toBe(largeData.length)
2363
- for (let i = 0; i < largeData.length; i++) {
2364
- expect(result[i]).toBe(largeData[i])
2594
+ test.concurrent(
2595
+ `should return 404 on HEAD after Expires-At passes`,
2596
+ async () => {
2597
+ const streamPath = uniquePath(`expires-at-head`)
2598
+
2599
+ // Create stream that expires in 1 second
2600
+ const expiresAt = new Date(Date.now() + 1000).toISOString()
2601
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2602
+ method: `PUT`,
2603
+ headers: {
2604
+ "Content-Type": `text/plain`,
2605
+ "Stream-Expires-At": expiresAt,
2606
+ },
2607
+ })
2608
+ expect(createResponse.status).toBe(201)
2609
+
2610
+ // Verify stream exists immediately
2611
+ const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2612
+ method: `HEAD`,
2613
+ })
2614
+ expect(headBefore.status).toBe(200)
2615
+
2616
+ // Wait for expiry time to pass
2617
+ await sleep(1500)
2618
+
2619
+ // Stream should no longer exist
2620
+ const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2621
+ method: `HEAD`,
2622
+ })
2623
+ expect(headAfter.status).toBe(404)
2365
2624
  }
2366
- })
2625
+ )
2367
2626
 
2368
- test(`should handle large payload appropriately`, async () => {
2369
- const streamPath = `/v1/stream/large-payload-test-${Date.now()}`
2627
+ test.concurrent(
2628
+ `should return 404 on GET after Expires-At passes`,
2629
+ async () => {
2630
+ const streamPath = uniquePath(`expires-at-get`)
2370
2631
 
2371
- await fetch(`${getBaseUrl()}${streamPath}`, {
2372
- method: `PUT`,
2373
- headers: { "Content-Type": `application/octet-stream` },
2374
- })
2632
+ // Create stream that expires in 1 second
2633
+ const expiresAt = new Date(Date.now() + 1000).toISOString()
2634
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2635
+ method: `PUT`,
2636
+ headers: {
2637
+ "Content-Type": `text/plain`,
2638
+ "Stream-Expires-At": expiresAt,
2639
+ },
2640
+ body: `test data`,
2641
+ })
2642
+ expect(createResponse.status).toBe(201)
2375
2643
 
2376
- // Try to append very large payload (10MB)
2377
- const largeData = new Uint8Array(10 * 1024 * 1024)
2644
+ // Verify stream is readable immediately
2645
+ const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2646
+ method: `GET`,
2647
+ })
2648
+ expect(getBefore.status).toBe(200)
2378
2649
 
2379
- const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2380
- method: `POST`,
2381
- headers: { "Content-Type": `application/octet-stream` },
2382
- body: largeData,
2383
- })
2650
+ // Wait for expiry time to pass
2651
+ await sleep(1500)
2384
2652
 
2385
- // Server may accept it (200/204) or reject with 413
2386
- expect([200, 204, 413]).toContain(response.status)
2387
- }, 30000)
2653
+ // Stream should no longer exist
2654
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2655
+ method: `GET`,
2656
+ })
2657
+ expect(getAfter.status).toBe(404)
2658
+ }
2659
+ )
2660
+
2661
+ test.concurrent(
2662
+ `should return 404 on POST append after Expires-At passes`,
2663
+ async () => {
2664
+ const streamPath = uniquePath(`expires-at-post`)
2665
+
2666
+ // Create stream that expires in 1 second
2667
+ const expiresAt = new Date(Date.now() + 1000).toISOString()
2668
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2669
+ method: `PUT`,
2670
+ headers: {
2671
+ "Content-Type": `text/plain`,
2672
+ "Stream-Expires-At": expiresAt,
2673
+ },
2674
+ })
2675
+ expect(createResponse.status).toBe(201)
2676
+
2677
+ // Verify append works immediately
2678
+ const postBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2679
+ method: `POST`,
2680
+ headers: { "Content-Type": `text/plain` },
2681
+ body: `appended data`,
2682
+ })
2683
+ expect(postBefore.status).toBe(204)
2684
+
2685
+ // Wait for expiry time to pass
2686
+ await sleep(1500)
2687
+
2688
+ // Append should fail - stream no longer exists
2689
+ const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2690
+ method: `POST`,
2691
+ headers: { "Content-Type": `text/plain` },
2692
+ body: `more data`,
2693
+ })
2694
+ expect(postAfter.status).toBe(404)
2695
+ }
2696
+ )
2697
+
2698
+ test.concurrent(
2699
+ `should allow recreating stream after TTL expires`,
2700
+ async () => {
2701
+ const streamPath = uniquePath(`ttl-recreate`)
2702
+
2703
+ // Create stream with 1 second TTL
2704
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2705
+ method: `PUT`,
2706
+ headers: {
2707
+ "Content-Type": `text/plain`,
2708
+ "Stream-TTL": `1`,
2709
+ },
2710
+ body: `original data`,
2711
+ })
2712
+ expect(createResponse.status).toBe(201)
2713
+
2714
+ // Wait for TTL to expire
2715
+ await sleep(1500)
2716
+
2717
+ // Recreate stream with different config - should succeed (201)
2718
+ const recreateResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2719
+ method: `PUT`,
2720
+ headers: {
2721
+ "Content-Type": `application/json`,
2722
+ "Stream-TTL": `3600`,
2723
+ },
2724
+ body: `["new data"]`,
2725
+ })
2726
+ expect(recreateResponse.status).toBe(201)
2727
+
2728
+ // Verify the new stream is accessible
2729
+ const getResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2730
+ method: `GET`,
2731
+ })
2732
+ expect(getResponse.status).toBe(200)
2733
+ const body = await getResponse.text()
2734
+ expect(body).toContain(`new data`)
2735
+ }
2736
+ )
2388
2737
  })
2389
2738
 
2390
2739
  // ============================================================================
2391
- // Read-Your-Writes Consistency
2740
+ // Caching and ETag Tests
2392
2741
  // ============================================================================
2393
2742
 
2394
- describe(`Read-Your-Writes Consistency`, () => {
2395
- test(`should immediately read message after append`, async () => {
2396
- const streamPath = `/v1/stream/ryw-test-${Date.now()}`
2743
+ describe(`Caching and ETag`, () => {
2744
+ test(`should generate ETag on GET responses`, async () => {
2745
+ const streamPath = `/v1/stream/etag-generate-test-${Date.now()}`
2397
2746
 
2398
- // Create stream and append
2399
2747
  await fetch(`${getBaseUrl()}${streamPath}`, {
2400
2748
  method: `PUT`,
2401
2749
  headers: { "Content-Type": `text/plain` },
2402
- body: `initial`,
2750
+ body: `test data`,
2403
2751
  })
2404
2752
 
2405
- // Immediately read - should see the data
2406
2753
  const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2407
2754
  method: `GET`,
2408
2755
  })
2409
2756
 
2410
- const text = await response.text()
2411
- expect(text).toBe(`initial`)
2757
+ expect(response.status).toBe(200)
2758
+ const etag = response.headers.get(`etag`)
2759
+ expect(etag).toBeDefined()
2760
+ expect(etag!.length).toBeGreaterThan(0)
2412
2761
  })
2413
2762
 
2414
- test(`should immediately read multiple appends`, async () => {
2415
- const streamPath = `/v1/stream/ryw-multi-test-${Date.now()}`
2763
+ test(`should return 304 Not Modified for matching If-None-Match`, async () => {
2764
+ const streamPath = `/v1/stream/etag-304-test-${Date.now()}`
2416
2765
 
2417
- // Create stream
2766
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2767
+ method: `PUT`,
2768
+ headers: { "Content-Type": `text/plain` },
2769
+ body: `test data`,
2770
+ })
2771
+
2772
+ // First request to get ETag
2773
+ const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
2774
+ method: `GET`,
2775
+ })
2776
+
2777
+ const etag = response1.headers.get(`etag`)
2778
+ expect(etag).toBeDefined()
2779
+
2780
+ // Second request with If-None-Match - MUST return 304
2781
+ const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
2782
+ method: `GET`,
2783
+ headers: {
2784
+ "If-None-Match": etag!,
2785
+ },
2786
+ })
2787
+
2788
+ expect(response2.status).toBe(304)
2789
+ // 304 should have empty body
2790
+ const text = await response2.text()
2791
+ expect(text).toBe(``)
2792
+ })
2793
+
2794
+ test(`should return 200 for non-matching If-None-Match`, async () => {
2795
+ const streamPath = `/v1/stream/etag-mismatch-test-${Date.now()}`
2796
+
2797
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2798
+ method: `PUT`,
2799
+ headers: { "Content-Type": `text/plain` },
2800
+ body: `test data`,
2801
+ })
2802
+
2803
+ // Request with wrong ETag - should return 200 with data
2804
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2805
+ method: `GET`,
2806
+ headers: {
2807
+ "If-None-Match": `"wrong-etag"`,
2808
+ },
2809
+ })
2810
+
2811
+ expect(response.status).toBe(200)
2812
+ const text = await response.text()
2813
+ expect(text).toBe(`test data`)
2814
+ })
2815
+
2816
+ test(`should return new ETag after data changes`, async () => {
2817
+ const streamPath = `/v1/stream/etag-change-test-${Date.now()}`
2818
+
2819
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2820
+ method: `PUT`,
2821
+ headers: { "Content-Type": `text/plain` },
2822
+ body: `initial`,
2823
+ })
2824
+
2825
+ // Get initial ETag
2826
+ const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
2827
+ method: `GET`,
2828
+ })
2829
+ const etag1 = response1.headers.get(`etag`)
2830
+
2831
+ // Append more data
2832
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2833
+ method: `POST`,
2834
+ headers: { "Content-Type": `text/plain` },
2835
+ body: ` more`,
2836
+ })
2837
+
2838
+ // Get new ETag
2839
+ const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
2840
+ method: `GET`,
2841
+ })
2842
+ const etag2 = response2.headers.get(`etag`)
2843
+
2844
+ // ETags should be different
2845
+ expect(etag1).not.toBe(etag2)
2846
+
2847
+ // Old ETag should now return 200 (not 304)
2848
+ const response3 = await fetch(`${getBaseUrl()}${streamPath}`, {
2849
+ method: `GET`,
2850
+ headers: {
2851
+ "If-None-Match": etag1!,
2852
+ },
2853
+ })
2854
+ expect(response3.status).toBe(200)
2855
+ })
2856
+ })
2857
+
2858
+ // ============================================================================
2859
+ // Chunking and Large Payloads
2860
+ // ============================================================================
2861
+
2862
+ describe(`Chunking and Large Payloads`, () => {
2863
+ test(`should handle chunk-size pagination correctly`, async () => {
2864
+ const streamPath = `/v1/stream/chunk-pagination-test-${Date.now()}`
2865
+
2866
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2867
+ method: `PUT`,
2868
+ headers: { "Content-Type": `application/octet-stream` },
2869
+ })
2870
+
2871
+ // Append a large amount of data (100KB)
2872
+ const largeData = new Uint8Array(100 * 1024)
2873
+ for (let i = 0; i < largeData.length; i++) {
2874
+ largeData[i] = i % 256
2875
+ }
2876
+
2877
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2878
+ method: `POST`,
2879
+ headers: { "Content-Type": `application/octet-stream` },
2880
+ body: largeData,
2881
+ })
2882
+
2883
+ // Read back using pagination
2884
+ const accumulated: Array<number> = []
2885
+ let currentOffset: string | null = null
2886
+ let previousOffset: string | null = null
2887
+ let iterations = 0
2888
+ const maxIterations = 1000
2889
+
2890
+ while (iterations < maxIterations) {
2891
+ iterations++
2892
+
2893
+ const url: string = currentOffset
2894
+ ? `${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(currentOffset)}`
2895
+ : `${getBaseUrl()}${streamPath}`
2896
+
2897
+ const response: Response = await fetch(url, { method: `GET` })
2898
+ expect(response.status).toBe(200)
2899
+
2900
+ const buffer = await response.arrayBuffer()
2901
+ const data = new Uint8Array(buffer)
2902
+
2903
+ if (data.length > 0) {
2904
+ accumulated.push(...Array.from(data))
2905
+ }
2906
+
2907
+ const nextOffset: string | null =
2908
+ response.headers.get(STREAM_OFFSET_HEADER)
2909
+ const upToDate = response.headers.get(STREAM_UP_TO_DATE_HEADER)
2910
+
2911
+ if (upToDate === `true` && data.length === 0) {
2912
+ break
2913
+ }
2914
+
2915
+ expect(nextOffset).toBeDefined()
2916
+
2917
+ // Verify offset progresses
2918
+ if (nextOffset === currentOffset && data.length === 0) {
2919
+ break
2920
+ }
2921
+
2922
+ // Verify monotonic progression
2923
+ if (previousOffset && nextOffset) {
2924
+ expect(nextOffset >= previousOffset).toBe(true)
2925
+ }
2926
+
2927
+ previousOffset = currentOffset
2928
+ currentOffset = nextOffset
2929
+ }
2930
+
2931
+ // Verify we got all the data
2932
+ const result = new Uint8Array(accumulated)
2933
+ expect(result.length).toBe(largeData.length)
2934
+ for (let i = 0; i < largeData.length; i++) {
2935
+ expect(result[i]).toBe(largeData[i])
2936
+ }
2937
+ })
2938
+
2939
+ test(`should handle large payload appropriately`, async () => {
2940
+ const streamPath = `/v1/stream/large-payload-test-${Date.now()}`
2941
+
2942
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2943
+ method: `PUT`,
2944
+ headers: { "Content-Type": `application/octet-stream` },
2945
+ })
2946
+
2947
+ // Try to append very large payload (10MB)
2948
+ const largeData = new Uint8Array(10 * 1024 * 1024)
2949
+
2950
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2951
+ method: `POST`,
2952
+ headers: { "Content-Type": `application/octet-stream` },
2953
+ body: largeData,
2954
+ })
2955
+
2956
+ // Server may accept it (200/204) or reject with 413
2957
+ expect([200, 204, 413]).toContain(response.status)
2958
+ }, 30000)
2959
+ })
2960
+
2961
+ // ============================================================================
2962
+ // Read-Your-Writes Consistency
2963
+ // ============================================================================
2964
+
2965
+ describe(`Read-Your-Writes Consistency`, () => {
2966
+ test(`should immediately read message after append`, async () => {
2967
+ const streamPath = `/v1/stream/ryw-test-${Date.now()}`
2968
+
2969
+ // Create stream and append
2970
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2971
+ method: `PUT`,
2972
+ headers: { "Content-Type": `text/plain` },
2973
+ body: `initial`,
2974
+ })
2975
+
2976
+ // Immediately read - should see the data
2977
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
2978
+ method: `GET`,
2979
+ })
2980
+
2981
+ const text = await response.text()
2982
+ expect(text).toBe(`initial`)
2983
+ })
2984
+
2985
+ test(`should immediately read multiple appends`, async () => {
2986
+ const streamPath = `/v1/stream/ryw-multi-test-${Date.now()}`
2987
+
2988
+ // Create stream
2418
2989
  await fetch(`${getBaseUrl()}${streamPath}`, {
2419
2990
  method: `PUT`,
2420
2991
  headers: { "Content-Type": `text/plain` },
@@ -2842,25 +3413,19 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2842
3413
  expect(received).toContain(`data: line3`)
2843
3414
  })
2844
3415
 
2845
- test(`should generate unique, monotonically increasing offsets in SSE mode`, async () => {
2846
- const streamPath = `/v1/stream/sse-monotonic-offset-test-${Date.now()}`
3416
+ test(`should prevent CRLF injection in payloads - embedded event boundaries become literal data`, async () => {
3417
+ const streamPath = `/v1/stream/sse-crlf-injection-test-${Date.now()}`
3418
+
3419
+ // Payload attempts to inject a fake control event via CRLF sequences
3420
+ // If vulnerable, this would terminate the current event and inject a new one
3421
+ const maliciousPayload = `safe content\r\n\r\nevent: control\r\ndata: {"injected":true}\r\n\r\nmore safe content`
2847
3422
 
2848
- // Create stream
2849
3423
  await fetch(`${getBaseUrl()}${streamPath}`, {
2850
3424
  method: `PUT`,
2851
3425
  headers: { "Content-Type": `text/plain` },
3426
+ body: maliciousPayload,
2852
3427
  })
2853
3428
 
2854
- // Append multiple messages
2855
- for (let i = 0; i < 5; i++) {
2856
- await fetch(`${getBaseUrl()}${streamPath}`, {
2857
- method: `POST`,
2858
- headers: { "Content-Type": `text/plain` },
2859
- body: `message ${i}`,
2860
- })
2861
- }
2862
-
2863
- // Make SSE request
2864
3429
  const { response, received } = await fetchSSE(
2865
3430
  `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
2866
3431
  { untilContent: `event: control` }
@@ -2868,26 +3433,180 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2868
3433
 
2869
3434
  expect(response.status).toBe(200)
2870
3435
 
2871
- // Extract all control event offsets
2872
- const controlLines = received
2873
- .split(`\n`)
2874
- .filter((l) => l.startsWith(`data: `) && l.includes(`streamNextOffset`))
3436
+ // Parse all events from the response
3437
+ const events = parseSSEEvents(received)
2875
3438
 
2876
- const offsets: Array<string> = []
2877
- for (const line of controlLines) {
2878
- const payload = line.slice(`data: `.length)
2879
- const data = JSON.parse(payload)
2880
- offsets.push(data[`streamNextOffset`])
2881
- }
3439
+ // Should have exactly 1 data event and 1 control event (the real one from server)
3440
+ const dataEvents = events.filter((e) => e.type === `data`)
3441
+ const controlEvents = events.filter((e) => e.type === `control`)
2882
3442
 
2883
- // Verify offsets are unique and strictly increasing (lexicographically)
2884
- for (let i = 1; i < offsets.length; i++) {
2885
- expect(offsets[i]! > offsets[i - 1]!).toBe(true)
2886
- }
3443
+ expect(dataEvents.length).toBe(1)
3444
+ expect(controlEvents.length).toBe(1)
3445
+
3446
+ // The "injected" control event should NOT exist as a real event
3447
+ // Instead, "event: control" should appear as literal text within the data
3448
+ const dataContent = dataEvents[0]!.data
3449
+ expect(dataContent).toContain(`event: control`)
3450
+ expect(dataContent).toContain(`data: {"injected":true}`)
3451
+
3452
+ // The real control event should have server-generated fields, not injected ones
3453
+ const controlContent = JSON.parse(controlEvents[0]!.data)
3454
+ expect(controlContent.injected).toBeUndefined()
3455
+ expect(controlContent.streamNextOffset).toBeDefined()
2887
3456
  })
2888
3457
 
2889
- test(`should support reconnection with last known offset`, async () => {
2890
- const streamPath = `/v1/stream/sse-reconnect-test-${Date.now()}`
3458
+ test(`should prevent CRLF injection - LF-only attack vectors`, async () => {
3459
+ const streamPath = `/v1/stream/sse-lf-injection-test-${Date.now()}`
3460
+
3461
+ // Attempt injection using Unix-style line endings only
3462
+ const maliciousPayload = `start\n\nevent: data\ndata: fake-event\n\nend`
3463
+
3464
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3465
+ method: `PUT`,
3466
+ headers: { "Content-Type": `text/plain` },
3467
+ body: maliciousPayload,
3468
+ })
3469
+
3470
+ const { response, received } = await fetchSSE(
3471
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3472
+ { untilContent: `event: control` }
3473
+ )
3474
+
3475
+ expect(response.status).toBe(200)
3476
+
3477
+ const events = parseSSEEvents(received)
3478
+ const dataEvents = events.filter((e) => e.type === `data`)
3479
+
3480
+ // Should be exactly 1 data event (the injected one should be escaped)
3481
+ expect(dataEvents.length).toBe(1)
3482
+
3483
+ // The payload should be preserved as literal content, including the
3484
+ // "event: data" and "data: fake-event" as text, not parsed as SSE commands
3485
+ const dataContent = dataEvents[0]!.data
3486
+ expect(dataContent).toContain(`event: data`)
3487
+ expect(dataContent).toContain(`data: fake-event`)
3488
+ })
3489
+
3490
+ test(`should prevent CRLF injection - carriage return only attack vectors`, async () => {
3491
+ const streamPath = `/v1/stream/sse-cr-injection-test-${Date.now()}`
3492
+
3493
+ // Attempt injection using CR-only line endings (per SSE spec, CR is a valid line terminator)
3494
+ const maliciousPayload = `start\r\revent: control\rdata: {"cr_injected":true}\r\rend`
3495
+
3496
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3497
+ method: `PUT`,
3498
+ headers: { "Content-Type": `text/plain` },
3499
+ body: maliciousPayload,
3500
+ })
3501
+
3502
+ const { response, received } = await fetchSSE(
3503
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3504
+ { untilContent: `event: control` }
3505
+ )
3506
+
3507
+ expect(response.status).toBe(200)
3508
+
3509
+ const events = parseSSEEvents(received)
3510
+ const controlEvents = events.filter((e) => e.type === `control`)
3511
+
3512
+ // Should have exactly 1 control event (the real one from server)
3513
+ expect(controlEvents.length).toBe(1)
3514
+
3515
+ // The real control event should not contain injected fields
3516
+ const controlContent = JSON.parse(controlEvents[0]!.data)
3517
+ expect(controlContent.cr_injected).toBeUndefined()
3518
+ expect(controlContent.streamNextOffset).toBeDefined()
3519
+ })
3520
+
3521
+ test(`should handle JSON payloads with embedded newlines safely`, async () => {
3522
+ const streamPath = `/v1/stream/sse-json-newline-test-${Date.now()}`
3523
+
3524
+ // JSON content that contains literal newlines in string values
3525
+ // These should be JSON-escaped, but we test that even if they're not,
3526
+ // SSE encoding handles them safely
3527
+ const jsonPayload = JSON.stringify({
3528
+ message: `line1\nline2\nline3`,
3529
+ attack: `try\r\n\r\nevent: control\r\ndata: {"bad":true}`,
3530
+ })
3531
+
3532
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3533
+ method: `PUT`,
3534
+ headers: { "Content-Type": `application/json` },
3535
+ body: jsonPayload,
3536
+ })
3537
+
3538
+ const { response, received } = await fetchSSE(
3539
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3540
+ { untilContent: `event: control` }
3541
+ )
3542
+
3543
+ expect(response.status).toBe(200)
3544
+
3545
+ const events = parseSSEEvents(received)
3546
+ const dataEvents = events.filter((e) => e.type === `data`)
3547
+ const controlEvents = events.filter((e) => e.type === `control`)
3548
+
3549
+ expect(dataEvents.length).toBe(1)
3550
+ expect(controlEvents.length).toBe(1)
3551
+
3552
+ // Parse the data event - should be valid JSON array wrapping the original object
3553
+ const parsedData = JSON.parse(dataEvents[0]!.data)
3554
+ expect(Array.isArray(parsedData)).toBe(true)
3555
+ expect(parsedData[0].message).toBe(`line1\nline2\nline3`)
3556
+ expect(parsedData[0].attack).toContain(`event: control`)
3557
+
3558
+ // Control event should be the real server-generated one
3559
+ const controlContent = JSON.parse(controlEvents[0]!.data)
3560
+ expect(controlContent.bad).toBeUndefined()
3561
+ expect(controlContent.streamNextOffset).toBeDefined()
3562
+ })
3563
+
3564
+ test(`should generate unique, monotonically increasing offsets in SSE mode`, async () => {
3565
+ const streamPath = `/v1/stream/sse-monotonic-offset-test-${Date.now()}`
3566
+
3567
+ // Create stream
3568
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3569
+ method: `PUT`,
3570
+ headers: { "Content-Type": `text/plain` },
3571
+ })
3572
+
3573
+ // Append multiple messages
3574
+ for (let i = 0; i < 5; i++) {
3575
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3576
+ method: `POST`,
3577
+ headers: { "Content-Type": `text/plain` },
3578
+ body: `message ${i}`,
3579
+ })
3580
+ }
3581
+
3582
+ // Make SSE request
3583
+ const { response, received } = await fetchSSE(
3584
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3585
+ { untilContent: `event: control` }
3586
+ )
3587
+
3588
+ expect(response.status).toBe(200)
3589
+
3590
+ // Extract all control event offsets
3591
+ const controlLines = received
3592
+ .split(`\n`)
3593
+ .filter((l) => l.startsWith(`data: `) && l.includes(`streamNextOffset`))
3594
+
3595
+ const offsets: Array<string> = []
3596
+ for (const line of controlLines) {
3597
+ const payload = line.slice(`data: `.length)
3598
+ const data = JSON.parse(payload)
3599
+ offsets.push(data[`streamNextOffset`])
3600
+ }
3601
+
3602
+ // Verify offsets are unique and strictly increasing (lexicographically)
3603
+ for (let i = 1; i < offsets.length; i++) {
3604
+ expect(offsets[i]! > offsets[i - 1]!).toBe(true)
3605
+ }
3606
+ })
3607
+
3608
+ test(`should support reconnection with last known offset`, async () => {
3609
+ const streamPath = `/v1/stream/sse-reconnect-test-${Date.now()}`
2891
3610
 
2892
3611
  // Create stream with initial data
2893
3612
  await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -3341,7 +4060,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3341
4060
  headers: { "Content-Type": `application/octet-stream` },
3342
4061
  body: chunk,
3343
4062
  })
3344
- expect([200, 204]).toContain(response.status)
4063
+ expect(response.status).toBe(204)
3345
4064
  }
3346
4065
 
3347
4066
  // Calculate expected result
@@ -3492,7 +4211,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3492
4211
  headers: { "Content-Type": `application/octet-stream` },
3493
4212
  body: op.data as BodyInit,
3494
4213
  })
3495
- expect([200, 204]).toContain(response.status)
4214
+ expect(response.status).toBe(204)
3496
4215
 
3497
4216
  // Track what we appended
3498
4217
  appendedData.push(...Array.from(op.data))
@@ -3619,7 +4338,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3619
4338
  ),
3620
4339
  { numRuns: 25 }
3621
4340
  )
3622
- })
4341
+ }, 15000)
3623
4342
 
3624
4343
  test(`read-your-writes: data is immediately visible after append`, async () => {
3625
4344
  await fc.assert(
@@ -3643,7 +4362,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3643
4362
  body: data,
3644
4363
  }
3645
4364
  )
3646
- expect([200, 204]).toContain(appendResponse.status)
4365
+ expect(appendResponse.status).toBe(204)
3647
4366
 
3648
4367
  // Immediately read back
3649
4368
  const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
@@ -3802,7 +4521,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3802
4521
  },
3803
4522
  body: `data-${seq}`,
3804
4523
  })
3805
- expect([200, 204]).toContain(response.status)
4524
+ expect(response.status).toBe(204)
3806
4525
  }
3807
4526
 
3808
4527
  return true
@@ -3839,7 +4558,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3839
4558
  },
3840
4559
  body: `first`,
3841
4560
  })
3842
- expect([200, 204]).toContain(response1.status)
4561
+ expect(response1.status).toBe(204)
3843
4562
 
3844
4563
  // Second append with smaller seq should be rejected
3845
4564
  const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -3859,5 +4578,1523 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3859
4578
  )
3860
4579
  })
3861
4580
  })
4581
+
4582
+ describe(`Concurrent Writer Stress Tests`, () => {
4583
+ test(`concurrent writers with sequence numbers - server handles gracefully`, async () => {
4584
+ const streamPath = `/v1/stream/concurrent-seq-${Date.now()}-${Math.random().toString(36).slice(2)}`
4585
+
4586
+ // Create stream
4587
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4588
+ method: `PUT`,
4589
+ headers: { "Content-Type": `text/plain` },
4590
+ })
4591
+
4592
+ // Try to write with same seq from multiple "writers" concurrently
4593
+ const numWriters = 5
4594
+ const seqValue = `seq-001`
4595
+
4596
+ const writePromises = Array.from({ length: numWriters }, (_, i) =>
4597
+ fetch(`${getBaseUrl()}${streamPath}`, {
4598
+ method: `POST`,
4599
+ headers: {
4600
+ "Content-Type": `text/plain`,
4601
+ [STREAM_SEQ_HEADER]: seqValue,
4602
+ },
4603
+ body: `writer-${i}`,
4604
+ })
4605
+ )
4606
+
4607
+ const responses = await Promise.all(writePromises)
4608
+ const statuses = responses.map((r) => r.status)
4609
+
4610
+ // Server should handle concurrent writes gracefully
4611
+ // All responses should be valid (success or conflict)
4612
+ for (const status of statuses) {
4613
+ expect([200, 204, 409]).toContain(status)
4614
+ }
4615
+
4616
+ // At least one should succeed
4617
+ const successes = statuses.filter((s) => s === 200 || s === 204)
4618
+ expect(successes.length).toBeGreaterThanOrEqual(1)
4619
+
4620
+ // Read back - should have exactly one write's data
4621
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
4622
+ const content = await readResponse.text()
4623
+
4624
+ // Content should contain data from exactly one writer
4625
+ const matchingWriters = Array.from({ length: numWriters }, (_, i) =>
4626
+ content.includes(`writer-${i}`)
4627
+ ).filter(Boolean)
4628
+ expect(matchingWriters.length).toBeGreaterThanOrEqual(1)
4629
+ })
4630
+
4631
+ test(`concurrent writers racing with incrementing seq values`, async () => {
4632
+ await fc.assert(
4633
+ fc.asyncProperty(
4634
+ fc.integer({ min: 3, max: 8 }), // Number of writers
4635
+ async (numWriters) => {
4636
+ const streamPath = `/v1/stream/concurrent-race-${Date.now()}-${Math.random().toString(36).slice(2)}`
4637
+
4638
+ // Create stream
4639
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4640
+ method: `PUT`,
4641
+ headers: { "Content-Type": `text/plain` },
4642
+ })
4643
+
4644
+ // Each writer gets a unique seq value (padded for lexicographic ordering)
4645
+ const writePromises = Array.from({ length: numWriters }, (_, i) =>
4646
+ fetch(`${getBaseUrl()}${streamPath}`, {
4647
+ method: `POST`,
4648
+ headers: {
4649
+ "Content-Type": `text/plain`,
4650
+ [STREAM_SEQ_HEADER]: String(i).padStart(4, `0`),
4651
+ },
4652
+ body: `data-${i}`,
4653
+ })
4654
+ )
4655
+
4656
+ const responses = await Promise.all(writePromises)
4657
+
4658
+ // With concurrent writes, some may succeed (200/204) and some may conflict (409)
4659
+ // due to out-of-order arrival at the server. All responses should be valid.
4660
+ const successIndices: Array<number> = []
4661
+ for (let i = 0; i < responses.length; i++) {
4662
+ expect([200, 204, 409]).toContain(responses[i]!.status)
4663
+ if (
4664
+ responses[i]!.status === 200 ||
4665
+ responses[i]!.status === 204
4666
+ ) {
4667
+ successIndices.push(i)
4668
+ }
4669
+ }
4670
+
4671
+ // At least one write should succeed
4672
+ expect(successIndices.length).toBeGreaterThanOrEqual(1)
4673
+
4674
+ // Read back and verify successful writes are present
4675
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
4676
+ const content = await readResponse.text()
4677
+
4678
+ // All successful writes should have their data in the stream
4679
+ for (const i of successIndices) {
4680
+ expect(content).toContain(`data-${i}`)
4681
+ }
4682
+
4683
+ return true
4684
+ }
4685
+ ),
4686
+ { numRuns: 10 }
4687
+ )
4688
+ })
4689
+
4690
+ test(`concurrent appends without seq - all data is persisted`, async () => {
4691
+ const streamPath = `/v1/stream/concurrent-no-seq-${Date.now()}-${Math.random().toString(36).slice(2)}`
4692
+
4693
+ // Create stream
4694
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4695
+ method: `PUT`,
4696
+ headers: { "Content-Type": `text/plain` },
4697
+ })
4698
+
4699
+ const numWriters = 10
4700
+ const writePromises = Array.from({ length: numWriters }, (_, i) =>
4701
+ fetch(`${getBaseUrl()}${streamPath}`, {
4702
+ method: `POST`,
4703
+ headers: { "Content-Type": `text/plain` },
4704
+ body: `concurrent-${i}`,
4705
+ })
4706
+ )
4707
+
4708
+ const responses = await Promise.all(writePromises)
4709
+
4710
+ // All should succeed
4711
+ for (const response of responses) {
4712
+ expect([200, 204]).toContain(response.status)
4713
+ }
4714
+
4715
+ // All offsets that are returned should be valid (non-null)
4716
+ const offsets = responses.map((r) =>
4717
+ r.headers.get(STREAM_OFFSET_HEADER)
4718
+ )
4719
+ for (const offset of offsets) {
4720
+ expect(offset).not.toBeNull()
4721
+ }
4722
+
4723
+ // Read back and verify all data is present (the key invariant)
4724
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
4725
+ const content = await readResponse.text()
4726
+
4727
+ for (let i = 0; i < numWriters; i++) {
4728
+ expect(content).toContain(`concurrent-${i}`)
4729
+ }
4730
+ })
4731
+
4732
+ test(`mixed readers and writers - readers see consistent state`, async () => {
4733
+ const streamPath = `/v1/stream/concurrent-rw-${Date.now()}-${Math.random().toString(36).slice(2)}`
4734
+
4735
+ // Create stream with initial data
4736
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4737
+ method: `PUT`,
4738
+ headers: { "Content-Type": `text/plain` },
4739
+ })
4740
+
4741
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4742
+ method: `POST`,
4743
+ headers: { "Content-Type": `text/plain` },
4744
+ body: `initial`,
4745
+ })
4746
+
4747
+ // Launch concurrent readers and writers
4748
+ const numOps = 20
4749
+ const operations = Array.from({ length: numOps }, (_, i) => {
4750
+ if (i % 2 === 0) {
4751
+ // Writer
4752
+ return fetch(`${getBaseUrl()}${streamPath}`, {
4753
+ method: `POST`,
4754
+ headers: { "Content-Type": `text/plain` },
4755
+ body: `write-${i}`,
4756
+ })
4757
+ } else {
4758
+ // Reader
4759
+ return fetch(`${getBaseUrl()}${streamPath}`)
4760
+ }
4761
+ })
4762
+
4763
+ const responses = await Promise.all(operations)
4764
+
4765
+ // All operations should succeed
4766
+ // Writers (even indices) return 200 or 204, readers (odd indices) return 200
4767
+ responses.forEach((response, i) => {
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
+ }
4775
+ })
4776
+
4777
+ // Final read should have all writes
4778
+ const finalRead = await fetch(`${getBaseUrl()}${streamPath}`)
4779
+ const content = await finalRead.text()
4780
+
4781
+ // Initial data should be present
4782
+ expect(content).toContain(`initial`)
4783
+
4784
+ // All writes should be present
4785
+ for (let i = 0; i < numOps; i += 2) {
4786
+ expect(content).toContain(`write-${i}`)
4787
+ }
4788
+ })
4789
+ })
4790
+
4791
+ describe(`State Hash Verification`, () => {
4792
+ /**
4793
+ * Simple hash function for content verification.
4794
+ * Uses FNV-1a algorithm for deterministic hashing.
4795
+ */
4796
+ function hashContent(data: Uint8Array): string {
4797
+ let hash = 2166136261 // FNV offset basis
4798
+ for (const byte of data) {
4799
+ hash ^= byte
4800
+ hash = Math.imul(hash, 16777619) // FNV prime
4801
+ hash = hash >>> 0 // Convert to unsigned 32-bit
4802
+ }
4803
+ return hash.toString(16).padStart(8, `0`)
4804
+ }
4805
+
4806
+ test(`replay produces identical content hash`, async () => {
4807
+ await fc.assert(
4808
+ fc.asyncProperty(
4809
+ // Generate a sequence of appends
4810
+ fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), {
4811
+ minLength: 1,
4812
+ maxLength: 10,
4813
+ }),
4814
+ async (chunks) => {
4815
+ // Create first stream and append data
4816
+ const streamPath1 = `/v1/stream/hash-verify-1-${Date.now()}-${Math.random().toString(36).slice(2)}`
4817
+ await fetch(`${getBaseUrl()}${streamPath1}`, {
4818
+ method: `PUT`,
4819
+ headers: { "Content-Type": `application/octet-stream` },
4820
+ })
4821
+
4822
+ for (const chunk of chunks) {
4823
+ await fetch(`${getBaseUrl()}${streamPath1}`, {
4824
+ method: `POST`,
4825
+ headers: { "Content-Type": `application/octet-stream` },
4826
+ body: chunk,
4827
+ })
4828
+ }
4829
+
4830
+ // Read and hash first stream
4831
+ const response1 = await fetch(`${getBaseUrl()}${streamPath1}`)
4832
+ const data1 = new Uint8Array(await response1.arrayBuffer())
4833
+ const hash1 = hashContent(data1)
4834
+
4835
+ // Create second stream and replay same operations
4836
+ const streamPath2 = `/v1/stream/hash-verify-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
4837
+ await fetch(`${getBaseUrl()}${streamPath2}`, {
4838
+ method: `PUT`,
4839
+ headers: { "Content-Type": `application/octet-stream` },
4840
+ })
4841
+
4842
+ for (const chunk of chunks) {
4843
+ await fetch(`${getBaseUrl()}${streamPath2}`, {
4844
+ method: `POST`,
4845
+ headers: { "Content-Type": `application/octet-stream` },
4846
+ body: chunk,
4847
+ })
4848
+ }
4849
+
4850
+ // Read and hash second stream
4851
+ const response2 = await fetch(`${getBaseUrl()}${streamPath2}`)
4852
+ const data2 = new Uint8Array(await response2.arrayBuffer())
4853
+ const hash2 = hashContent(data2)
4854
+
4855
+ // Hashes must match
4856
+ expect(hash1).toBe(hash2)
4857
+ expect(data1.length).toBe(data2.length)
4858
+
4859
+ return true
4860
+ }
4861
+ ),
4862
+ { numRuns: 15 }
4863
+ )
4864
+ }, 15000)
4865
+
4866
+ test(`content hash changes with each append`, async () => {
4867
+ const streamPath = `/v1/stream/hash-changes-${Date.now()}-${Math.random().toString(36).slice(2)}`
4868
+
4869
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4870
+ method: `PUT`,
4871
+ headers: { "Content-Type": `application/octet-stream` },
4872
+ })
4873
+
4874
+ const hashes: Array<string> = []
4875
+
4876
+ // Append 5 chunks and verify hash changes each time
4877
+ for (let i = 0; i < 5; i++) {
4878
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4879
+ method: `POST`,
4880
+ headers: { "Content-Type": `application/octet-stream` },
4881
+ body: new Uint8Array([i, i + 1, i + 2]),
4882
+ })
4883
+
4884
+ const response = await fetch(`${getBaseUrl()}${streamPath}`)
4885
+ const data = new Uint8Array(await response.arrayBuffer())
4886
+ hashes.push(hashContent(data))
4887
+ }
4888
+
4889
+ // All hashes should be unique
4890
+ const uniqueHashes = new Set(hashes)
4891
+ expect(uniqueHashes.size).toBe(5)
4892
+ })
4893
+
4894
+ test(`empty stream has consistent hash`, async () => {
4895
+ // Create two empty streams
4896
+ const streamPath1 = `/v1/stream/empty-hash-1-${Date.now()}-${Math.random().toString(36).slice(2)}`
4897
+ const streamPath2 = `/v1/stream/empty-hash-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
4898
+
4899
+ await fetch(`${getBaseUrl()}${streamPath1}`, {
4900
+ method: `PUT`,
4901
+ headers: { "Content-Type": `application/octet-stream` },
4902
+ })
4903
+ await fetch(`${getBaseUrl()}${streamPath2}`, {
4904
+ method: `PUT`,
4905
+ headers: { "Content-Type": `application/octet-stream` },
4906
+ })
4907
+
4908
+ // Read both
4909
+ const response1 = await fetch(`${getBaseUrl()}${streamPath1}`)
4910
+ const response2 = await fetch(`${getBaseUrl()}${streamPath2}`)
4911
+
4912
+ const data1 = new Uint8Array(await response1.arrayBuffer())
4913
+ const data2 = new Uint8Array(await response2.arrayBuffer())
4914
+
4915
+ // Both should be empty and have same hash
4916
+ expect(data1.length).toBe(0)
4917
+ expect(data2.length).toBe(0)
4918
+ expect(hashContent(data1)).toBe(hashContent(data2))
4919
+ })
4920
+
4921
+ test(`deterministic ordering - same data in same order produces same hash`, async () => {
4922
+ await fc.assert(
4923
+ fc.asyncProperty(
4924
+ fc.array(fc.uint8Array({ minLength: 1, maxLength: 50 }), {
4925
+ minLength: 2,
4926
+ maxLength: 5,
4927
+ }),
4928
+ async (chunks) => {
4929
+ // Create two streams with same data in same order
4930
+ const hashes: Array<string> = []
4931
+
4932
+ for (let run = 0; run < 2; run++) {
4933
+ const streamPath = `/v1/stream/order-hash-${run}-${Date.now()}-${Math.random().toString(36).slice(2)}`
4934
+
4935
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4936
+ method: `PUT`,
4937
+ headers: { "Content-Type": `application/octet-stream` },
4938
+ })
4939
+
4940
+ // Append in order
4941
+ for (const chunk of chunks) {
4942
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4943
+ method: `POST`,
4944
+ headers: { "Content-Type": `application/octet-stream` },
4945
+ body: chunk,
4946
+ })
4947
+ }
4948
+
4949
+ const response = await fetch(`${getBaseUrl()}${streamPath}`)
4950
+ const data = new Uint8Array(await response.arrayBuffer())
4951
+ hashes.push(hashContent(data))
4952
+ }
4953
+
4954
+ expect(hashes[0]).toBe(hashes[1])
4955
+
4956
+ return true
4957
+ }
4958
+ ),
4959
+ { numRuns: 10 }
4960
+ )
4961
+ })
4962
+ })
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
+ })
3862
6099
  })
3863
6100
  }