@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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/{src-BJQjRfnf.cjs → src-CfXXlBaO.cjs} +1593 -18
- package/dist/{src-BtF2jQ-Q.js → src-GWuAOela.js} +1593 -18
- package/dist/test-runner.cjs +1 -1
- package/dist/test-runner.js +1 -1
- package/package.json +2 -2
- package/src/index.ts +3103 -866
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1161
|
-
const streamPath = `/v1/stream/offset-
|
|
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: `
|
|
1379
|
+
body: `historical data`,
|
|
1167
1380
|
})
|
|
1168
1381
|
|
|
1169
|
-
|
|
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(
|
|
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
|
|
1177
|
-
const streamPath = `/v1/stream/offset-
|
|
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: `
|
|
1401
|
+
body: `initial data`,
|
|
1183
1402
|
})
|
|
1184
1403
|
|
|
1185
|
-
|
|
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
|
-
|
|
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
|
|
1193
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
//
|
|
1202
|
-
await fetch(
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
|
1441
|
+
// Append new data
|
|
1219
1442
|
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1220
1443
|
method: `POST`,
|
|
1221
1444
|
headers: { "Content-Type": `text/plain` },
|
|
1222
|
-
body: `
|
|
1445
|
+
body: `new data`,
|
|
1223
1446
|
})
|
|
1224
1447
|
|
|
1225
|
-
//
|
|
1226
|
-
const
|
|
1227
|
-
`${getBaseUrl()}${streamPath}?offset=${
|
|
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
|
|
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
|
|
1238
|
-
const streamPath = `/v1/stream/
|
|
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
|
|
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
|
-
//
|
|
1248
|
-
const
|
|
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(
|
|
1262
|
-
const text = await
|
|
1473
|
+
expect(response.status).toBe(200)
|
|
1474
|
+
const text = await response.text()
|
|
1263
1475
|
expect(text).toBe(``)
|
|
1264
|
-
expect(
|
|
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
|
-
|
|
1273
|
-
|
|
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": `
|
|
1486
|
+
headers: { "Content-Type": `application/json` },
|
|
1487
|
+
body: `[{"event": "historical"}]`,
|
|
1280
1488
|
})
|
|
1281
1489
|
|
|
1282
|
-
//
|
|
1283
|
-
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1284
|
-
method: `
|
|
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(
|
|
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
|
|
1293
|
-
const streamPath = `/v1/stream/
|
|
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
|
|
1297
|
-
|
|
1508
|
+
// Create text stream with data
|
|
1509
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1298
1510
|
method: `PUT`,
|
|
1299
1511
|
headers: { "Content-Type": `text/plain` },
|
|
1300
|
-
body:
|
|
1512
|
+
body: `historical data`,
|
|
1301
1513
|
})
|
|
1302
1514
|
|
|
1303
|
-
|
|
1304
|
-
const
|
|
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
|
-
|
|
1313
|
-
expect(
|
|
1314
|
-
expect(
|
|
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
|
|
1318
|
-
const streamPath = `/v1/stream/
|
|
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
|
|
1532
|
+
// Create stream with data
|
|
1321
1533
|
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1322
1534
|
method: `PUT`,
|
|
1323
1535
|
headers: { "Content-Type": `text/plain` },
|
|
1324
|
-
body: `
|
|
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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
//
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
1356
|
-
|
|
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
|
|
1360
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
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
|
-
//
|
|
1371
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
//
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
|
1390
|
-
const streamPath = `/v1/stream/
|
|
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: `
|
|
1600
|
+
body: `existing data`,
|
|
1396
1601
|
})
|
|
1397
1602
|
|
|
1398
|
-
|
|
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
|
-
|
|
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
|
|
1406
|
-
const streamPath = `/v1/stream/
|
|
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: `
|
|
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=
|
|
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(
|
|
1647
|
+
expect(response.status).toBe(404)
|
|
1422
1648
|
})
|
|
1423
1649
|
|
|
1424
|
-
test(`should
|
|
1425
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
//
|
|
1433
|
-
await fetch(
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
|
|
1448
|
-
[STREAM_SEQ_HEADER]: `B`,
|
|
1449
|
-
},
|
|
1450
|
-
body: `second`,
|
|
1685
|
+
headers: { "Content-Type": `text/plain` },
|
|
1686
|
+
body: `first data`,
|
|
1451
1687
|
})
|
|
1452
1688
|
|
|
1453
|
-
|
|
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
|
|
1457
|
-
const streamPath = `/v1/stream/
|
|
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": `
|
|
1467
|
-
body: binaryData,
|
|
1704
|
+
headers: { "Content-Type": `text/plain` },
|
|
1468
1705
|
})
|
|
1469
1706
|
|
|
1470
|
-
//
|
|
1471
|
-
const response = await
|
|
1472
|
-
|
|
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
|
-
|
|
1476
|
-
const result = new Uint8Array(buffer)
|
|
1713
|
+
expect(response.status).toBe(200)
|
|
1477
1714
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
|
1485
|
-
const streamPath = `/v1/stream/
|
|
1728
|
+
test(`should reject malformed offset (contains comma)`, async () => {
|
|
1729
|
+
const streamPath = `/v1/stream/offset-comma-test-${Date.now()}`
|
|
1486
1730
|
|
|
1487
|
-
|
|
1731
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1488
1732
|
method: `PUT`,
|
|
1489
1733
|
headers: { "Content-Type": `text/plain` },
|
|
1734
|
+
body: `test`,
|
|
1490
1735
|
})
|
|
1491
1736
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1499
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
1508
|
-
|
|
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
|
|
1519
|
-
const streamPath = `/v1/stream/
|
|
1760
|
+
test(`should support resumable reads (no duplicate data)`, async () => {
|
|
1761
|
+
const streamPath = `/v1/stream/resumable-test-${Date.now()}`
|
|
1520
1762
|
|
|
1521
|
-
|
|
1763
|
+
// Create stream
|
|
1764
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1522
1765
|
method: `PUT`,
|
|
1766
|
+
headers: { "Content-Type": `text/plain` },
|
|
1523
1767
|
})
|
|
1524
1768
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
1531
|
-
const
|
|
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: `
|
|
1788
|
+
method: `POST`,
|
|
1535
1789
|
headers: { "Content-Type": `text/plain` },
|
|
1536
|
-
body: `
|
|
1790
|
+
body: `chunk2`,
|
|
1537
1791
|
})
|
|
1538
1792
|
|
|
1539
|
-
//
|
|
1540
|
-
const
|
|
1541
|
-
`${getBaseUrl()}${streamPath}?offset
|
|
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(
|
|
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
|
-
|
|
1558
|
-
|
|
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
|
-
//
|
|
1567
|
-
const
|
|
1568
|
-
|
|
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(
|
|
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
|
-
|
|
1578
|
-
|
|
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
|
-
//
|
|
1587
|
-
const response = await fetch(
|
|
1588
|
-
|
|
1589
|
-
{
|
|
1590
|
-
|
|
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(
|
|
1857
|
+
expect(response.status).toBe(400)
|
|
1858
|
+
})
|
|
1595
1859
|
|
|
1596
|
-
|
|
1597
|
-
const
|
|
1598
|
-
|
|
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
|
-
//
|
|
1602
|
-
|
|
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
|
|
1606
|
-
const streamPath = `/v1/stream/
|
|
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: `
|
|
1892
|
+
body: `chunk1`,
|
|
1612
1893
|
})
|
|
1613
1894
|
|
|
1614
|
-
//
|
|
1615
|
-
const response1 = await fetch(
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1635
|
-
const
|
|
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
|
-
//
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1930
|
+
// Create stream
|
|
1931
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1932
|
+
method: `PUT`,
|
|
1933
|
+
headers: { "Content-Type": `text/plain` },
|
|
1934
|
+
})
|
|
1642
1935
|
|
|
1643
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1706
|
-
|
|
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
|
-
|
|
1960
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1710
1961
|
method: `PUT`,
|
|
1711
|
-
headers: {
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
|
1721
|
-
const streamPath = `/v1/stream/
|
|
1973
|
+
test(`should reject multiple offset parameters`, async () => {
|
|
1974
|
+
const streamPath = `/v1/stream/multi-offset-test-${Date.now()}`
|
|
1722
1975
|
|
|
1723
|
-
|
|
1976
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1724
1977
|
method: `PUT`,
|
|
1725
|
-
headers: {
|
|
1726
|
-
|
|
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
|
|
1735
|
-
const streamPath = `/v1/stream/
|
|
1992
|
+
test(`should enforce case-sensitive seq ordering`, async () => {
|
|
1993
|
+
const streamPath = `/v1/stream/case-seq-test-${Date.now()}`
|
|
1736
1994
|
|
|
1737
|
-
|
|
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
|
-
|
|
2005
|
+
[STREAM_SEQ_HEADER]: `a`,
|
|
1742
2006
|
},
|
|
2007
|
+
body: `first`,
|
|
1743
2008
|
})
|
|
1744
2009
|
|
|
1745
|
-
|
|
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: `
|
|
2013
|
+
method: `POST`,
|
|
1753
2014
|
headers: {
|
|
1754
2015
|
"Content-Type": `text/plain`,
|
|
1755
|
-
|
|
2016
|
+
[STREAM_SEQ_HEADER]: `B`,
|
|
1756
2017
|
},
|
|
2018
|
+
body: `second`,
|
|
1757
2019
|
})
|
|
1758
2020
|
|
|
1759
|
-
expect(response.status).toBe(
|
|
2021
|
+
expect(response.status).toBe(409)
|
|
1760
2022
|
})
|
|
1761
2023
|
|
|
1762
|
-
test(`should
|
|
1763
|
-
const streamPath = `/v1/stream/
|
|
2024
|
+
test(`should handle binary data with integrity`, async () => {
|
|
2025
|
+
const streamPath = `/v1/stream/binary-test-${Date.now()}`
|
|
1764
2026
|
|
|
1765
|
-
|
|
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
|
-
|
|
1769
|
-
"Stream-Expires-At": `not-a-timestamp`,
|
|
1770
|
-
},
|
|
2034
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2035
|
+
body: binaryData,
|
|
1771
2036
|
})
|
|
1772
2037
|
|
|
1773
|
-
|
|
1774
|
-
|
|
2038
|
+
// Read back and verify byte-for-byte
|
|
2039
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2040
|
+
method: `GET`,
|
|
2041
|
+
})
|
|
1775
2042
|
|
|
1776
|
-
|
|
1777
|
-
const
|
|
2043
|
+
const buffer = await response.arrayBuffer()
|
|
2044
|
+
const result = new Uint8Array(buffer)
|
|
1778
2045
|
|
|
1779
|
-
|
|
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(
|
|
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
|
|
1793
|
-
const streamPath = `/v1/stream/
|
|
2069
|
+
test(`should reject missing Content-Type on POST`, async () => {
|
|
2070
|
+
const streamPath = `/v1/stream/missing-ct-post-test-${Date.now()}`
|
|
1794
2071
|
|
|
1795
|
-
//
|
|
1796
|
-
|
|
1797
|
-
|
|
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: `
|
|
1801
|
-
|
|
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(
|
|
2086
|
+
expect(response.status).toBe(400)
|
|
1808
2087
|
})
|
|
1809
2088
|
|
|
1810
|
-
test(`should
|
|
1811
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
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
|
-
|
|
1824
|
-
const
|
|
1825
|
-
|
|
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
|
|
1835
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
1842
|
-
"Stream-TTL": `3600`,
|
|
1843
|
-
},
|
|
2106
|
+
headers: { "Content-Type": `text/plain` },
|
|
2107
|
+
body: `test data`,
|
|
1844
2108
|
})
|
|
1845
2109
|
|
|
1846
|
-
//
|
|
1847
|
-
const response = await fetch(
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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(
|
|
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
|
-
//
|
|
2125
|
+
// Long-Poll Edge Cases
|
|
1861
2126
|
// ============================================================================
|
|
1862
2127
|
|
|
1863
|
-
describe(`
|
|
1864
|
-
test(`should
|
|
1865
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
1877
|
-
|
|
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
|
-
|
|
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
|
|
1889
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
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
|
-
//
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1948
|
-
|
|
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
|
-
//
|
|
1951
|
-
|
|
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
|
|
1958
|
-
const streamPath =
|
|
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
|
-
|
|
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
|
-
//
|
|
1972
|
-
const
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
-
|
|
1978
|
-
|
|
2193
|
+
expect(response1.status).toBe(200)
|
|
2194
|
+
const cursor1 = response1.headers.get(`Stream-Cursor`)
|
|
2195
|
+
expect(cursor1).toBeDefined()
|
|
1979
2196
|
|
|
1980
|
-
//
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
|
1988
|
-
`should return
|
|
2214
|
+
test(
|
|
2215
|
+
`should return Stream-Cursor, Stream-Up-To-Date and Stream-Next-Offset on 204 timeout`,
|
|
1989
2216
|
async () => {
|
|
1990
|
-
const streamPath =
|
|
2217
|
+
const streamPath = `/v1/stream/longpoll-204-headers-test-${Date.now()}`
|
|
1991
2218
|
|
|
1992
|
-
|
|
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
|
-
//
|
|
2014
|
-
const
|
|
2015
|
-
method: `
|
|
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
|
-
|
|
2020
|
-
|
|
2021
|
-
)
|
|
2228
|
+
const tailOffset = headResponse.headers.get(STREAM_OFFSET_HEADER)
|
|
2229
|
+
expect(tailOffset).toBeDefined()
|
|
2022
2230
|
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
const
|
|
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
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
-
|
|
2040
|
-
const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2041
|
-
method: `HEAD`,
|
|
2042
|
-
})
|
|
2043
|
-
expect(headBefore.status).toBe(200)
|
|
2245
|
+
clearTimeout(timeoutId)
|
|
2044
2246
|
|
|
2045
|
-
|
|
2046
|
-
|
|
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
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
const streamPath = uniquePath(`expires-at-get`)
|
|
2272
|
+
// ============================================================================
|
|
2273
|
+
// TTL and Expiry Edge Cases
|
|
2274
|
+
// ============================================================================
|
|
2060
2275
|
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
2080
|
-
|
|
2288
|
+
expect(response.status).toBe(400)
|
|
2289
|
+
})
|
|
2081
2290
|
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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
|
-
|
|
2096
|
-
|
|
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
|
-
|
|
2107
|
-
|
|
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
|
-
|
|
2115
|
-
|
|
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
|
-
|
|
2118
|
-
|
|
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
|
|
2128
|
-
|
|
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
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
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
|
-
|
|
2144
|
-
|
|
2330
|
+
expect(response.status).toBe(400)
|
|
2331
|
+
})
|
|
2145
2332
|
|
|
2146
|
-
|
|
2147
|
-
|
|
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
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
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
|
-
|
|
2170
|
-
// ============================================================================
|
|
2344
|
+
expect(response.status).toBe(400)
|
|
2345
|
+
})
|
|
2171
2346
|
|
|
2172
|
-
|
|
2173
|
-
|
|
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
|
-
|
|
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: `
|
|
2353
|
+
method: `PUT`,
|
|
2354
|
+
headers: {
|
|
2355
|
+
"Content-Type": `text/plain`,
|
|
2356
|
+
"Stream-Expires-At": expiresAt,
|
|
2357
|
+
},
|
|
2184
2358
|
})
|
|
2185
2359
|
|
|
2186
|
-
expect(response.status)
|
|
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
|
|
2193
|
-
const streamPath = `/v1/stream/
|
|
2363
|
+
test(`should accept Expires-At with timezone offset`, async () => {
|
|
2364
|
+
const streamPath = `/v1/stream/expires-offset-test-${Date.now()}`
|
|
2194
2365
|
|
|
2195
|
-
|
|
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: {
|
|
2198
|
-
|
|
2372
|
+
headers: {
|
|
2373
|
+
"Content-Type": `text/plain`,
|
|
2374
|
+
"Stream-Expires-At": expiresAt,
|
|
2375
|
+
},
|
|
2199
2376
|
})
|
|
2200
2377
|
|
|
2201
|
-
|
|
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: `
|
|
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
|
-
|
|
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: `
|
|
2396
|
+
method: `PUT`,
|
|
2212
2397
|
headers: {
|
|
2213
|
-
"
|
|
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
|
|
2224
|
-
const streamPath = `/v1/stream/
|
|
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: {
|
|
2229
|
-
|
|
2411
|
+
headers: {
|
|
2412
|
+
"Content-Type": `text/plain`,
|
|
2413
|
+
"Stream-TTL": `3600`,
|
|
2414
|
+
},
|
|
2230
2415
|
})
|
|
2231
2416
|
|
|
2232
|
-
//
|
|
2417
|
+
// PUT again with different TTL - should fail
|
|
2233
2418
|
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2234
|
-
method: `
|
|
2419
|
+
method: `PUT`,
|
|
2235
2420
|
headers: {
|
|
2236
|
-
"
|
|
2421
|
+
"Content-Type": `text/plain`,
|
|
2422
|
+
"Stream-TTL": `7200`,
|
|
2237
2423
|
},
|
|
2238
2424
|
})
|
|
2239
2425
|
|
|
2240
|
-
expect(response.status).toBe(
|
|
2241
|
-
const text = await response.text()
|
|
2242
|
-
expect(text).toBe(`test data`)
|
|
2426
|
+
expect(response.status).toBe(409)
|
|
2243
2427
|
})
|
|
2428
|
+
})
|
|
2244
2429
|
|
|
2245
|
-
|
|
2246
|
-
|
|
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: {
|
|
2251
|
-
|
|
2441
|
+
headers: {
|
|
2442
|
+
"Content-Type": `text/plain`,
|
|
2443
|
+
"Stream-TTL": `3600`,
|
|
2444
|
+
},
|
|
2252
2445
|
})
|
|
2253
2446
|
|
|
2254
|
-
|
|
2255
|
-
|
|
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
|
-
//
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2268
|
-
const
|
|
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
|
-
|
|
2274
|
-
expect(etag1).not.toBe(etag2)
|
|
2462
|
+
const expiresAt = new Date(Date.now() + 3600000).toISOString()
|
|
2275
2463
|
|
|
2276
|
-
//
|
|
2277
|
-
|
|
2278
|
-
method: `
|
|
2464
|
+
// Create with Expires-At
|
|
2465
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2466
|
+
method: `PUT`,
|
|
2279
2467
|
headers: {
|
|
2280
|
-
"
|
|
2468
|
+
"Content-Type": `text/plain`,
|
|
2469
|
+
"Stream-Expires-At": expiresAt,
|
|
2281
2470
|
},
|
|
2282
2471
|
})
|
|
2283
|
-
|
|
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
|
-
//
|
|
2486
|
+
// TTL Expiration Behavior Tests
|
|
2289
2487
|
// ============================================================================
|
|
2290
2488
|
|
|
2291
|
-
describe(`
|
|
2292
|
-
|
|
2293
|
-
|
|
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
|
-
|
|
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: {
|
|
2505
|
+
headers: {
|
|
2506
|
+
"Content-Type": `text/plain`,
|
|
2507
|
+
"Stream-TTL": `1`,
|
|
2508
|
+
},
|
|
2298
2509
|
})
|
|
2510
|
+
expect(createResponse.status).toBe(201)
|
|
2299
2511
|
|
|
2300
|
-
//
|
|
2301
|
-
const
|
|
2302
|
-
|
|
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
|
-
//
|
|
2313
|
-
|
|
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
|
-
|
|
2320
|
-
|
|
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
|
-
|
|
2323
|
-
|
|
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
|
-
|
|
2327
|
-
|
|
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
|
-
|
|
2330
|
-
|
|
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
|
-
|
|
2333
|
-
|
|
2334
|
-
}
|
|
2548
|
+
// Wait for TTL to expire
|
|
2549
|
+
await sleep(1500)
|
|
2335
2550
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
|
-
|
|
2341
|
-
|
|
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
|
-
|
|
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
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
-
//
|
|
2352
|
-
|
|
2353
|
-
expect(nextOffset >= previousOffset).toBe(true)
|
|
2354
|
-
}
|
|
2581
|
+
// Wait for TTL to expire
|
|
2582
|
+
await sleep(1500)
|
|
2355
2583
|
|
|
2356
|
-
|
|
2357
|
-
|
|
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
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
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(
|
|
2369
|
-
|
|
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
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
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
|
-
|
|
2377
|
-
|
|
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
|
-
|
|
2380
|
-
|
|
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
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
-
//
|
|
2740
|
+
// Caching and ETag Tests
|
|
2392
2741
|
// ============================================================================
|
|
2393
2742
|
|
|
2394
|
-
describe(`
|
|
2395
|
-
test(`should
|
|
2396
|
-
const streamPath = `/v1/stream/
|
|
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: `
|
|
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
|
-
|
|
2411
|
-
|
|
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
|
|
2415
|
-
const streamPath = `/v1/stream/
|
|
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
|
-
|
|
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
|
|
2846
|
-
const streamPath = `/v1/stream/sse-
|
|
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
|
-
//
|
|
2872
|
-
const
|
|
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
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
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
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
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
|
|
2890
|
-
const streamPath = `/v1/stream/sse-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|