@durable-streams/server-conformance-tests 0.2.0 → 0.2.2

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.
@@ -1932,19 +1932,6 @@ function runConformanceTests(options) {
1932
1932
  const response = await fetch(`${getBaseUrl()}${streamPath}?live=sse`, { method: `GET` });
1933
1933
  expect(response.status).toBe(400);
1934
1934
  });
1935
- test(`client should reject SSE mode for incompatible content types`, async () => {
1936
- const streamPath = `/v1/stream/sse-binary-test-${Date.now()}`;
1937
- const stream = await DurableStream.create({
1938
- url: `${getBaseUrl()}${streamPath}`,
1939
- contentType: `application/octet-stream`
1940
- });
1941
- await stream.append(new Uint8Array([
1942
- 1,
1943
- 2,
1944
- 3
1945
- ]));
1946
- await expect(stream.stream({ live: `sse` })).rejects.toThrow();
1947
- });
1948
1935
  test(`should stream data events via SSE`, async () => {
1949
1936
  const streamPath = `/v1/stream/sse-data-stream-test-${Date.now()}`;
1950
1937
  await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -2207,10 +2194,11 @@ function runConformanceTests(options) {
2207
2194
  body: `message 2`
2208
2195
  });
2209
2196
  let lastOffset = null;
2210
- const { response: response1, received: received1 } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2197
+ const { response: response1, received: received1 } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `upToDate` });
2211
2198
  expect(response1.status).toBe(200);
2212
- const controlLine = received1.split(`\n`).find((l) => l.startsWith(`data:`) && l.includes(`streamNextOffset`));
2213
- const controlPayload = controlLine.slice(`data:`.length);
2199
+ const controlLines = received1.split(`\n`).filter((l) => l.startsWith(`data:`) && l.includes(`streamNextOffset`));
2200
+ const lastControlLine = controlLines[controlLines.length - 1];
2201
+ const controlPayload = lastControlLine.slice(`data:`.length);
2214
2202
  lastOffset = JSON.parse(controlPayload)[`streamNextOffset`];
2215
2203
  expect(lastOffset).toBeDefined();
2216
2204
  await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -2224,6 +2212,242 @@ function runConformanceTests(options) {
2224
2212
  expect(received2).not.toContain(`message 1`);
2225
2213
  expect(received2).not.toContain(`message 2`);
2226
2214
  });
2215
+ test(`should auto-detect binary streams and return base64 encoded data in SSE mode`, async () => {
2216
+ const streamPath = `/v1/stream/sse-binary-base64-${Date.now()}`;
2217
+ const binaryData = new Uint8Array([
2218
+ 72,
2219
+ 101,
2220
+ 108,
2221
+ 108,
2222
+ 111
2223
+ ]);
2224
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2225
+ method: `PUT`,
2226
+ headers: { "Content-Type": `application/octet-stream` },
2227
+ body: binaryData
2228
+ });
2229
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2230
+ expect(response.status).toBe(200);
2231
+ expect(response.headers.get(`content-type`)).toBe(`text/event-stream`);
2232
+ const events = parseSSEEvents(received);
2233
+ const dataEvents = events.filter((e) => e.type === `data`);
2234
+ const controlEvents = events.filter((e) => e.type === `control`);
2235
+ expect(dataEvents.length).toBe(1);
2236
+ expect(controlEvents.length).toBe(1);
2237
+ const base64Data = dataEvents[0].data.replace(/[\n\r\s]/g, ``);
2238
+ expect(base64Data).toBe(`SGVsbG8=`);
2239
+ const controlData = JSON.parse(controlEvents[0].data);
2240
+ expect(controlData.streamNextOffset).toBeDefined();
2241
+ });
2242
+ test(`should include Stream-SSE-Data-Encoding header for binary streams`, async () => {
2243
+ const streamPath = `/v1/stream/sse-encoding-header-${Date.now()}`;
2244
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2245
+ method: `PUT`,
2246
+ headers: { "Content-Type": `application/octet-stream` },
2247
+ body: new Uint8Array([
2248
+ 1,
2249
+ 2,
2250
+ 3
2251
+ ])
2252
+ });
2253
+ const { response } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2254
+ expect(response.status).toBe(200);
2255
+ const encodingHeader = response.headers.get(`stream-sse-data-encoding`);
2256
+ expect(encodingHeader).toBe(`base64`);
2257
+ });
2258
+ test(`should NOT include Stream-SSE-Data-Encoding header for text/plain streams`, async () => {
2259
+ const streamPath = `/v1/stream/sse-text-no-encoding-${Date.now()}`;
2260
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2261
+ method: `PUT`,
2262
+ headers: { "Content-Type": `text/plain` },
2263
+ body: `hello world`
2264
+ });
2265
+ const { response } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2266
+ expect(response.status).toBe(200);
2267
+ const encodingHeader = response.headers.get(`stream-sse-data-encoding`);
2268
+ expect(encodingHeader).toBeNull();
2269
+ });
2270
+ test(`should NOT include Stream-SSE-Data-Encoding header for application/json streams`, async () => {
2271
+ const streamPath = `/v1/stream/sse-json-no-encoding-${Date.now()}`;
2272
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2273
+ method: `PUT`,
2274
+ headers: { "Content-Type": `application/json` },
2275
+ body: JSON.stringify({ message: `hello` })
2276
+ });
2277
+ const { response } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2278
+ expect(response.status).toBe(200);
2279
+ const encodingHeader = response.headers.get(`stream-sse-data-encoding`);
2280
+ expect(encodingHeader).toBeNull();
2281
+ });
2282
+ test(`should base64 encode data events only, control events remain JSON`, async () => {
2283
+ const streamPath = `/v1/stream/sse-base64-data-only-${Date.now()}`;
2284
+ const binaryData = new Uint8Array([
2285
+ 255,
2286
+ 254,
2287
+ 253
2288
+ ]);
2289
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2290
+ method: `PUT`,
2291
+ headers: { "Content-Type": `application/octet-stream` },
2292
+ body: binaryData
2293
+ });
2294
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2295
+ expect(response.status).toBe(200);
2296
+ const events = parseSSEEvents(received);
2297
+ const controlEvents = events.filter((e) => e.type === `control`);
2298
+ expect(controlEvents.length).toBe(1);
2299
+ const controlData = JSON.parse(controlEvents[0].data);
2300
+ expect(controlData.streamNextOffset).toBeDefined();
2301
+ expect(typeof controlData.streamNextOffset).toBe(`string`);
2302
+ expect(controlData.streamCursor).toBeDefined();
2303
+ });
2304
+ test(`should handle empty binary payload with auto-detected base64 encoding`, async () => {
2305
+ const streamPath = `/v1/stream/sse-base64-empty-${Date.now()}`;
2306
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2307
+ method: `PUT`,
2308
+ headers: { "Content-Type": `application/octet-stream` }
2309
+ });
2310
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2311
+ expect(response.status).toBe(200);
2312
+ const events = parseSSEEvents(received);
2313
+ const controlEvents = events.filter((e) => e.type === `control`);
2314
+ expect(controlEvents.length).toBeGreaterThanOrEqual(1);
2315
+ const controlData = JSON.parse(controlEvents[0].data);
2316
+ expect(controlData.upToDate).toBe(true);
2317
+ });
2318
+ test(`should handle large binary payload with auto-detected base64 encoding`, async () => {
2319
+ const streamPath = `/v1/stream/sse-base64-large-${Date.now()}`;
2320
+ const binaryData = new Uint8Array(1024);
2321
+ for (let i = 0; i < 1024; i++) binaryData[i] = i % 256;
2322
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2323
+ method: `PUT`,
2324
+ headers: { "Content-Type": `application/octet-stream` },
2325
+ body: binaryData
2326
+ });
2327
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, {
2328
+ untilContent: `event: control`,
2329
+ timeoutMs: 5e3
2330
+ });
2331
+ expect(response.status).toBe(200);
2332
+ const events = parseSSEEvents(received);
2333
+ const dataEvents = events.filter((e) => e.type === `data`);
2334
+ expect(dataEvents.length).toBe(1);
2335
+ const base64Data = dataEvents[0].data.replace(/[\n\r\s]/g, ``);
2336
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0));
2337
+ expect(decoded.length).toBe(1024);
2338
+ for (let i = 0; i < 1024; i++) expect(decoded[i]).toBe(i % 256);
2339
+ });
2340
+ test(`should handle binary data with special bytes using auto-detected base64 encoding`, async () => {
2341
+ const streamPath = `/v1/stream/sse-base64-special-bytes-${Date.now()}`;
2342
+ const binaryData = new Uint8Array([
2343
+ 0,
2344
+ 10,
2345
+ 13,
2346
+ 255,
2347
+ 254,
2348
+ 0,
2349
+ 10,
2350
+ 13
2351
+ ]);
2352
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2353
+ method: `PUT`,
2354
+ headers: { "Content-Type": `application/octet-stream` },
2355
+ body: binaryData
2356
+ });
2357
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2358
+ expect(response.status).toBe(200);
2359
+ const events = parseSSEEvents(received);
2360
+ const dataEvents = events.filter((e) => e.type === `data`);
2361
+ expect(dataEvents.length).toBe(1);
2362
+ const base64Data = dataEvents[0].data.replace(/[\n\r\s]/g, ``);
2363
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0));
2364
+ expect(decoded.length).toBe(8);
2365
+ expect(decoded[0]).toBe(0);
2366
+ expect(decoded[1]).toBe(10);
2367
+ expect(decoded[2]).toBe(13);
2368
+ expect(decoded[3]).toBe(255);
2369
+ expect(decoded[4]).toBe(254);
2370
+ });
2371
+ test(`should auto-detect base64 encoding for application/x-protobuf streams`, async () => {
2372
+ const streamPath = `/v1/stream/sse-base64-protobuf-${Date.now()}`;
2373
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2374
+ method: `PUT`,
2375
+ headers: { "Content-Type": `application/x-protobuf` },
2376
+ body: new Uint8Array([
2377
+ 8,
2378
+ 6,
2379
+ 18,
2380
+ 4,
2381
+ 110,
2382
+ 97,
2383
+ 109,
2384
+ 101
2385
+ ])
2386
+ });
2387
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2388
+ expect(response.status).toBe(200);
2389
+ const events = parseSSEEvents(received);
2390
+ const dataEvents = events.filter((e) => e.type === `data`);
2391
+ expect(dataEvents.length).toBe(1);
2392
+ const base64Data = dataEvents[0].data.replace(/[\n\r\s]/g, ``);
2393
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0));
2394
+ expect(decoded.length).toBe(8);
2395
+ expect(decoded[0]).toBe(8);
2396
+ expect(decoded[7]).toBe(101);
2397
+ });
2398
+ test(`should auto-detect base64 encoding for image/png streams`, async () => {
2399
+ const streamPath = `/v1/stream/sse-base64-image-${Date.now()}`;
2400
+ const pngHeader = new Uint8Array([
2401
+ 137,
2402
+ 80,
2403
+ 78,
2404
+ 71,
2405
+ 13,
2406
+ 10,
2407
+ 26,
2408
+ 10
2409
+ ]);
2410
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2411
+ method: `PUT`,
2412
+ headers: { "Content-Type": `image/png` },
2413
+ body: pngHeader
2414
+ });
2415
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
2416
+ expect(response.status).toBe(200);
2417
+ const events = parseSSEEvents(received);
2418
+ const dataEvents = events.filter((e) => e.type === `data`);
2419
+ expect(dataEvents.length).toBe(1);
2420
+ const base64Data = dataEvents[0].data.replace(/[\n\r\s]/g, ``);
2421
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0));
2422
+ expect(decoded.length).toBe(8);
2423
+ expect(decoded[0]).toBe(137);
2424
+ expect(decoded[1]).toBe(80);
2425
+ expect(decoded[2]).toBe(78);
2426
+ expect(decoded[3]).toBe(71);
2427
+ });
2428
+ test(`should handle offset=now with auto-detected base64 encoding for binary streams`, async () => {
2429
+ const streamPath = `/v1/stream/sse-base64-offset-now-${Date.now()}`;
2430
+ await fetch(`${getBaseUrl()}${streamPath}`, {
2431
+ method: `PUT`,
2432
+ headers: { "Content-Type": `application/octet-stream` },
2433
+ body: new Uint8Array([
2434
+ 1,
2435
+ 2,
2436
+ 3
2437
+ ])
2438
+ });
2439
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=now&live=sse`, { untilContent: `"upToDate"` });
2440
+ expect(response.status).toBe(200);
2441
+ const controlMatch = received.match(/event: control\s*\n\s*data:({[^}]+})/);
2442
+ expect(controlMatch).toBeDefined();
2443
+ if (controlMatch && controlMatch[1]) {
2444
+ const controlData = JSON.parse(controlMatch[1]);
2445
+ expect(controlData[`upToDate`]).toBe(true);
2446
+ }
2447
+ const events = parseSSEEvents(received);
2448
+ const dataEvents = events.filter((e) => e.type === `data`);
2449
+ expect(dataEvents.length).toBe(0);
2450
+ });
2227
2451
  });
2228
2452
  describe(`JSON Mode`, () => {
2229
2453
  test(`should allow PUT with empty array body (creates empty stream)`, async () => {
@@ -2745,7 +2969,7 @@ function runConformanceTests(options) {
2745
2969
  return true;
2746
2970
  }
2747
2971
  ), { numRuns: 25 });
2748
- }, 15e3);
2972
+ });
2749
2973
  test(`read-your-writes: data is immediately visible after append`, async () => {
2750
2974
  await fc.assert(fc.asyncProperty(fc.uint8Array({
2751
2975
  minLength: 1,
@@ -3085,7 +3309,7 @@ function runConformanceTests(options) {
3085
3309
  return true;
3086
3310
  }
3087
3311
  ), { numRuns: 15 });
3088
- }, 15e3);
3312
+ });
3089
3313
  test(`content hash changes with each append`, async () => {
3090
3314
  const streamPath = `/v1/stream/hash-changes-${Date.now()}-${Math.random().toString(36).slice(2)}`;
3091
3315
  await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -4078,6 +4302,724 @@ function runConformanceTests(options) {
4078
4302
  expect(r.status).toBe(400);
4079
4303
  });
4080
4304
  });
4305
+ describe(`Stream Closure`, () => {
4306
+ const STREAM_CLOSED_HEADER = `Stream-Closed`;
4307
+ describe(`Create with Stream-Closed`, () => {
4308
+ test(`create-closed-stream: PUT with Stream-Closed: true creates closed stream`, async () => {
4309
+ const streamPath = `/v1/stream/create-closed-${Date.now()}`;
4310
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
4311
+ method: `PUT`,
4312
+ headers: {
4313
+ "Content-Type": `text/plain`,
4314
+ [STREAM_CLOSED_HEADER]: `true`
4315
+ }
4316
+ });
4317
+ expect(response.status).toBe(201);
4318
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4319
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy();
4320
+ });
4321
+ test(`create-closed-stream-with-body: PUT with body + Stream-Closed: true`, async () => {
4322
+ const streamPath = `/v1/stream/create-closed-body-${Date.now()}`;
4323
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
4324
+ method: `PUT`,
4325
+ headers: {
4326
+ "Content-Type": `text/plain`,
4327
+ [STREAM_CLOSED_HEADER]: `true`
4328
+ },
4329
+ body: `initial content`
4330
+ });
4331
+ expect(response.status).toBe(201);
4332
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4333
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
4334
+ const content = await readResponse.text();
4335
+ expect(content).toBe(`initial content`);
4336
+ expect(readResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4337
+ });
4338
+ test(`create-closed-returns-header: Response includes Stream-Closed: true`, async () => {
4339
+ const streamPath = `/v1/stream/create-closed-header-${Date.now()}`;
4340
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
4341
+ method: `PUT`,
4342
+ headers: {
4343
+ "Content-Type": `text/plain`,
4344
+ [STREAM_CLOSED_HEADER]: `true`
4345
+ }
4346
+ });
4347
+ expect(response.status).toBe(201);
4348
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4349
+ });
4350
+ });
4351
+ describe(`Close Operations`, () => {
4352
+ test(`close-stream-empty-post: POST with Stream-Closed: true, empty body closes stream`, async () => {
4353
+ const streamPath = `/v1/stream/close-empty-${Date.now()}`;
4354
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4355
+ method: `PUT`,
4356
+ headers: { "Content-Type": `text/plain` }
4357
+ });
4358
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4359
+ method: `POST`,
4360
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4361
+ });
4362
+ expect(closeResponse.status).toBe(204);
4363
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4364
+ });
4365
+ test(`close-with-final-append: POST with body + Stream-Closed: true`, async () => {
4366
+ const streamPath = `/v1/stream/close-final-${Date.now()}`;
4367
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4368
+ method: `PUT`,
4369
+ headers: { "Content-Type": `text/plain` }
4370
+ });
4371
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4372
+ method: `POST`,
4373
+ headers: { "Content-Type": `text/plain` },
4374
+ body: `first message`
4375
+ });
4376
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4377
+ method: `POST`,
4378
+ headers: {
4379
+ "Content-Type": `text/plain`,
4380
+ [STREAM_CLOSED_HEADER]: `true`
4381
+ },
4382
+ body: `final message`
4383
+ });
4384
+ expect(closeResponse.status).toBe(204);
4385
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4386
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
4387
+ const content = await readResponse.text();
4388
+ expect(content).toBe(`first messagefinal message`);
4389
+ });
4390
+ test(`close-returns-offset-and-header: Response includes Stream-Next-Offset and Stream-Closed: true`, async () => {
4391
+ const streamPath = `/v1/stream/close-returns-${Date.now()}`;
4392
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4393
+ method: `PUT`,
4394
+ headers: { "Content-Type": `text/plain` },
4395
+ body: `content`
4396
+ });
4397
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4398
+ method: `POST`,
4399
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4400
+ });
4401
+ expect(closeResponse.status).toBe(204);
4402
+ expect(closeResponse.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy();
4403
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4404
+ });
4405
+ test(`close-idempotent: Closing already-closed stream (empty body) returns 204`, async () => {
4406
+ const streamPath = `/v1/stream/close-idempotent-${Date.now()}`;
4407
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4408
+ method: `PUT`,
4409
+ headers: { "Content-Type": `text/plain` }
4410
+ });
4411
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4412
+ method: `POST`,
4413
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4414
+ });
4415
+ expect(firstClose.status).toBe(204);
4416
+ const secondClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4417
+ method: `POST`,
4418
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4419
+ });
4420
+ expect(secondClose.status).toBe(204);
4421
+ expect(secondClose.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4422
+ });
4423
+ test(`close-only-ignores-content-type: Close-only with mismatched Content-Type still succeeds`, async () => {
4424
+ const streamPath = `/v1/stream/close-ignores-ct-${Date.now()}`;
4425
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4426
+ method: `PUT`,
4427
+ headers: { "Content-Type": `application/json` }
4428
+ });
4429
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4430
+ method: `POST`,
4431
+ headers: {
4432
+ "Content-Type": `text/plain`,
4433
+ [STREAM_CLOSED_HEADER]: `true`
4434
+ }
4435
+ });
4436
+ expect(closeResponse.status).toBe(204);
4437
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4438
+ });
4439
+ test(`append-to-closed-stream-409: Append to closed stream returns 409 with Stream-Closed: true header`, async () => {
4440
+ const streamPath = `/v1/stream/append-closed-${Date.now()}`;
4441
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4442
+ method: `PUT`,
4443
+ headers: { "Content-Type": `text/plain` }
4444
+ });
4445
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4446
+ method: `POST`,
4447
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4448
+ });
4449
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4450
+ method: `POST`,
4451
+ headers: { "Content-Type": `text/plain` },
4452
+ body: `should fail`
4453
+ });
4454
+ expect(appendResponse.status).toBe(409);
4455
+ expect(appendResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4456
+ });
4457
+ test(`append-and-close-to-closed-stream-409: POST with body + Stream-Closed: true to already-closed stream returns 409`, async () => {
4458
+ const streamPath = `/v1/stream/append-close-closed-${Date.now()}`;
4459
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4460
+ method: `PUT`,
4461
+ headers: { "Content-Type": `text/plain` }
4462
+ });
4463
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4464
+ method: `POST`,
4465
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4466
+ });
4467
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
4468
+ method: `POST`,
4469
+ headers: {
4470
+ "Content-Type": `text/plain`,
4471
+ [STREAM_CLOSED_HEADER]: `true`
4472
+ },
4473
+ body: `should fail`
4474
+ });
4475
+ expect(response.status).toBe(409);
4476
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4477
+ });
4478
+ });
4479
+ describe(`HEAD with Stream Closure`, () => {
4480
+ test(`head-closed-stream: HEAD returns Stream-Closed: true header`, async () => {
4481
+ const streamPath = `/v1/stream/head-closed-${Date.now()}`;
4482
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4483
+ method: `PUT`,
4484
+ headers: { "Content-Type": `text/plain` }
4485
+ });
4486
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4487
+ method: `POST`,
4488
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4489
+ });
4490
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
4491
+ expect(headResponse.status).toBe(200);
4492
+ expect(headResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4493
+ });
4494
+ test(`head-open-stream-no-closed-header: HEAD on open stream does NOT have Stream-Closed header`, async () => {
4495
+ const streamPath = `/v1/stream/head-open-${Date.now()}`;
4496
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4497
+ method: `PUT`,
4498
+ headers: { "Content-Type": `text/plain` }
4499
+ });
4500
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
4501
+ expect(headResponse.status).toBe(200);
4502
+ expect(headResponse.headers.get(STREAM_CLOSED_HEADER)).toBeNull();
4503
+ });
4504
+ });
4505
+ describe(`Read Closed Streams (Catch-up)`, () => {
4506
+ test(`read-closed-stream-at-tail: Returns Stream-Closed: true at tail of closed stream`, async () => {
4507
+ const streamPath = `/v1/stream/read-closed-tail-${Date.now()}`;
4508
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4509
+ method: `PUT`,
4510
+ headers: { "Content-Type": `text/plain` },
4511
+ body: `content`
4512
+ });
4513
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4514
+ method: `POST`,
4515
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4516
+ });
4517
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
4518
+ expect(readResponse.status).toBe(200);
4519
+ expect(await readResponse.text()).toBe(`content`);
4520
+ expect(readResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4521
+ expect(readResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
4522
+ });
4523
+ test(`read-closed-stream-partial-no-closed: Partial read of closed stream does NOT include Stream-Closed`, async () => {
4524
+ const streamPath = `/v1/stream/read-closed-partial-${Date.now()}`;
4525
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4526
+ method: `PUT`,
4527
+ headers: { "Content-Type": `text/plain` },
4528
+ body: `first`
4529
+ });
4530
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4531
+ method: `POST`,
4532
+ headers: { "Content-Type": `text/plain` },
4533
+ body: `second`
4534
+ });
4535
+ const secondOffset = appendResponse.headers.get(STREAM_OFFSET_HEADER);
4536
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4537
+ method: `POST`,
4538
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4539
+ });
4540
+ const partialRead = await fetch(`${getBaseUrl()}${streamPath}?offset=-1`);
4541
+ const partialContent = await partialRead.text();
4542
+ const nextOffset = partialRead.headers.get(STREAM_OFFSET_HEADER);
4543
+ if (nextOffset === secondOffset) expect(partialRead.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4544
+ else if (partialContent.length < `firstsecond`.length && partialContent.length > 0) expect(partialRead.headers.get(STREAM_CLOSED_HEADER)).toBeNull();
4545
+ if (partialContent === `firstsecond`) expect(partialRead.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4546
+ });
4547
+ test(`read-closed-stream-empty-body-eof: At tail of closed stream: 200 OK, empty body, Stream-Closed: true`, async () => {
4548
+ const streamPath = `/v1/stream/read-closed-eof-${Date.now()}`;
4549
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4550
+ method: `PUT`,
4551
+ headers: { "Content-Type": `text/plain` },
4552
+ body: `content`
4553
+ });
4554
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4555
+ method: `POST`,
4556
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4557
+ });
4558
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER);
4559
+ const eofRead = await fetch(`${getBaseUrl()}${streamPath}?offset=${tailOffset}`);
4560
+ expect(eofRead.status).toBe(200);
4561
+ expect(await eofRead.text()).toBe(``);
4562
+ expect(eofRead.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4563
+ expect(eofRead.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
4564
+ });
4565
+ });
4566
+ describe(`Long-poll with Stream Closure`, () => {
4567
+ test(`longpoll-closed-stream-immediate: No wait when closed stream at tail, returns immediately`, async () => {
4568
+ const streamPath = `/v1/stream/longpoll-closed-${Date.now()}`;
4569
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4570
+ method: `PUT`,
4571
+ headers: { "Content-Type": `text/plain` }
4572
+ });
4573
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4574
+ method: `POST`,
4575
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4576
+ });
4577
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER);
4578
+ const startTime = Date.now();
4579
+ const longpollResponse = await fetch(`${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`);
4580
+ const elapsed = Date.now() - startTime;
4581
+ expect(longpollResponse.status).toBe(204);
4582
+ expect(longpollResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4583
+ expect(elapsed).toBeLessThan(5e3);
4584
+ }, getLongPollTestTimeoutMs());
4585
+ test(`longpoll-closed-returns-204-with-header: Returns 204 with Stream-Closed: true`, async () => {
4586
+ const streamPath = `/v1/stream/longpoll-closed-204-${Date.now()}`;
4587
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4588
+ method: `PUT`,
4589
+ headers: { "Content-Type": `text/plain` },
4590
+ body: `data`
4591
+ });
4592
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4593
+ method: `POST`,
4594
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4595
+ });
4596
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER);
4597
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`);
4598
+ expect(response.status).toBe(204);
4599
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4600
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
4601
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy();
4602
+ }, getLongPollTestTimeoutMs());
4603
+ });
4604
+ describe(`SSE with Stream Closure`, () => {
4605
+ test(`sse-closed-stream-control-event: Final control event has streamClosed: true`, async () => {
4606
+ const streamPath = `/v1/stream/sse-closed-control-${Date.now()}`;
4607
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4608
+ method: `PUT`,
4609
+ headers: { "Content-Type": `text/plain` },
4610
+ body: `content`
4611
+ });
4612
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4613
+ method: `POST`,
4614
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4615
+ });
4616
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER);
4617
+ const { received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=sse`, {
4618
+ timeoutMs: 5e3,
4619
+ untilContent: `streamClosed`
4620
+ });
4621
+ const events = parseSSEEvents(received);
4622
+ const controlEvents = events.filter((e) => e.type === `control`);
4623
+ expect(controlEvents.length).toBeGreaterThan(0);
4624
+ const lastControl = controlEvents[controlEvents.length - 1];
4625
+ const controlData = JSON.parse(lastControl.data);
4626
+ expect(controlData.streamClosed).toBe(true);
4627
+ });
4628
+ test(`sse-closed-stream-no-cursor: streamCursor omitted when streamClosed is true`, async () => {
4629
+ const streamPath = `/v1/stream/sse-closed-no-cursor-${Date.now()}`;
4630
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4631
+ method: `PUT`,
4632
+ headers: { "Content-Type": `text/plain` }
4633
+ });
4634
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4635
+ method: `POST`,
4636
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4637
+ });
4638
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER);
4639
+ const { received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=sse`, {
4640
+ timeoutMs: 5e3,
4641
+ untilContent: `streamClosed`
4642
+ });
4643
+ const events = parseSSEEvents(received);
4644
+ const controlEvents = events.filter((e) => e.type === `control`);
4645
+ expect(controlEvents.length).toBeGreaterThan(0);
4646
+ const lastControl = controlEvents[controlEvents.length - 1];
4647
+ const controlData = JSON.parse(lastControl.data);
4648
+ expect(controlData.streamClosed).toBe(true);
4649
+ expect(controlData.streamCursor).toBeUndefined();
4650
+ });
4651
+ test(`sse-closed-stream-connection-closes: Connection closes after final event`, async () => {
4652
+ const streamPath = `/v1/stream/sse-closed-conn-${Date.now()}`;
4653
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4654
+ method: `PUT`,
4655
+ headers: { "Content-Type": `text/plain` },
4656
+ body: `data`
4657
+ });
4658
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4659
+ method: `POST`,
4660
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4661
+ });
4662
+ const controller = new AbortController();
4663
+ const startTime = Date.now();
4664
+ try {
4665
+ const response = await fetch(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { signal: controller.signal });
4666
+ if (response.body) {
4667
+ const reader = response.body.getReader();
4668
+ let received = ``;
4669
+ let chunkCount = 0;
4670
+ while (chunkCount < 20) {
4671
+ const { done, value } = await reader.read();
4672
+ if (done) break;
4673
+ received += new TextDecoder().decode(value);
4674
+ chunkCount++;
4675
+ }
4676
+ expect(received).toContain(`streamClosed`);
4677
+ }
4678
+ } catch {} finally {
4679
+ controller.abort();
4680
+ }
4681
+ const elapsed = Date.now() - startTime;
4682
+ expect(elapsed).toBeLessThan(1e4);
4683
+ });
4684
+ });
4685
+ describe(`Idempotent Producers with Stream Closure`, () => {
4686
+ const PRODUCER_ID_HEADER = `Producer-Id`;
4687
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`;
4688
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`;
4689
+ test(`idempotent-close-with-append: Close with final append using producer headers`, async () => {
4690
+ const streamPath = `/v1/stream/idempotent-close-append-${Date.now()}`;
4691
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4692
+ method: `PUT`,
4693
+ headers: { "Content-Type": `text/plain` }
4694
+ });
4695
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4696
+ method: `POST`,
4697
+ headers: {
4698
+ "Content-Type": `text/plain`,
4699
+ [STREAM_CLOSED_HEADER]: `true`,
4700
+ [PRODUCER_ID_HEADER]: `test-producer`,
4701
+ [PRODUCER_EPOCH_HEADER]: `0`,
4702
+ [PRODUCER_SEQ_HEADER]: `0`
4703
+ },
4704
+ body: `final message`
4705
+ });
4706
+ expect(closeResponse.status).toBe(200);
4707
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4708
+ expect(closeResponse.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`0`);
4709
+ expect(closeResponse.headers.get(PRODUCER_SEQ_HEADER)).toBe(`0`);
4710
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
4711
+ expect(await readResponse.text()).toBe(`final message`);
4712
+ });
4713
+ test(`idempotent-close-only-with-producer-headers: Close-only with producer headers updates state`, async () => {
4714
+ const streamPath = `/v1/stream/idempotent-close-only-${Date.now()}`;
4715
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4716
+ method: `PUT`,
4717
+ headers: { "Content-Type": `text/plain` }
4718
+ });
4719
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4720
+ method: `POST`,
4721
+ headers: {
4722
+ "Content-Type": `text/plain`,
4723
+ [PRODUCER_ID_HEADER]: `test-producer`,
4724
+ [PRODUCER_EPOCH_HEADER]: `0`,
4725
+ [PRODUCER_SEQ_HEADER]: `0`
4726
+ },
4727
+ body: `message`
4728
+ });
4729
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4730
+ method: `POST`,
4731
+ headers: {
4732
+ [STREAM_CLOSED_HEADER]: `true`,
4733
+ [PRODUCER_ID_HEADER]: `test-producer`,
4734
+ [PRODUCER_EPOCH_HEADER]: `0`,
4735
+ [PRODUCER_SEQ_HEADER]: `1`
4736
+ }
4737
+ });
4738
+ expect(closeResponse.status).toBe(204);
4739
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4740
+ expect(closeResponse.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`0`);
4741
+ expect(closeResponse.headers.get(PRODUCER_SEQ_HEADER)).toBe(`1`);
4742
+ });
4743
+ test(`idempotent-close-duplicate-returns-204: Duplicate close (same tuple) returns 204`, async () => {
4744
+ const streamPath = `/v1/stream/idempotent-close-dup-${Date.now()}`;
4745
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4746
+ method: `PUT`,
4747
+ headers: { "Content-Type": `text/plain` }
4748
+ });
4749
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4750
+ method: `POST`,
4751
+ headers: {
4752
+ "Content-Type": `text/plain`,
4753
+ [STREAM_CLOSED_HEADER]: `true`,
4754
+ [PRODUCER_ID_HEADER]: `test-producer`,
4755
+ [PRODUCER_EPOCH_HEADER]: `0`,
4756
+ [PRODUCER_SEQ_HEADER]: `0`
4757
+ },
4758
+ body: `final`
4759
+ });
4760
+ expect(firstClose.status).toBe(200);
4761
+ const duplicateClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4762
+ method: `POST`,
4763
+ headers: {
4764
+ "Content-Type": `text/plain`,
4765
+ [STREAM_CLOSED_HEADER]: `true`,
4766
+ [PRODUCER_ID_HEADER]: `test-producer`,
4767
+ [PRODUCER_EPOCH_HEADER]: `0`,
4768
+ [PRODUCER_SEQ_HEADER]: `0`
4769
+ },
4770
+ body: `final`
4771
+ });
4772
+ expect(duplicateClose.status).toBe(204);
4773
+ expect(duplicateClose.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4774
+ });
4775
+ test(`idempotent-close-different-tuple-returns-409: Different producer/seq gets 409`, async () => {
4776
+ const streamPath = `/v1/stream/idempotent-close-diff-${Date.now()}`;
4777
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4778
+ method: `PUT`,
4779
+ headers: { "Content-Type": `text/plain` }
4780
+ });
4781
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4782
+ method: `POST`,
4783
+ headers: {
4784
+ "Content-Type": `text/plain`,
4785
+ [STREAM_CLOSED_HEADER]: `true`,
4786
+ [PRODUCER_ID_HEADER]: `producer-A`,
4787
+ [PRODUCER_EPOCH_HEADER]: `0`,
4788
+ [PRODUCER_SEQ_HEADER]: `0`
4789
+ },
4790
+ body: `final`
4791
+ });
4792
+ const differentProducer = await fetch(`${getBaseUrl()}${streamPath}`, {
4793
+ method: `POST`,
4794
+ headers: {
4795
+ "Content-Type": `text/plain`,
4796
+ [STREAM_CLOSED_HEADER]: `true`,
4797
+ [PRODUCER_ID_HEADER]: `producer-B`,
4798
+ [PRODUCER_EPOCH_HEADER]: `0`,
4799
+ [PRODUCER_SEQ_HEADER]: `0`
4800
+ },
4801
+ body: `should fail`
4802
+ });
4803
+ expect(differentProducer.status).toBe(409);
4804
+ expect(differentProducer.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4805
+ });
4806
+ test(`idempotent-close-different-seq-returns-409: Same producer, different seq gets 409`, async () => {
4807
+ const streamPath = `/v1/stream/idempotent-close-diff-seq-${Date.now()}`;
4808
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4809
+ method: `PUT`,
4810
+ headers: { "Content-Type": `text/plain` }
4811
+ });
4812
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4813
+ method: `POST`,
4814
+ headers: {
4815
+ "Content-Type": `text/plain`,
4816
+ [STREAM_CLOSED_HEADER]: `true`,
4817
+ [PRODUCER_ID_HEADER]: `test-producer`,
4818
+ [PRODUCER_EPOCH_HEADER]: `0`,
4819
+ [PRODUCER_SEQ_HEADER]: `0`
4820
+ },
4821
+ body: `final`
4822
+ });
4823
+ const differentSeq = await fetch(`${getBaseUrl()}${streamPath}`, {
4824
+ method: `POST`,
4825
+ headers: {
4826
+ "Content-Type": `text/plain`,
4827
+ [STREAM_CLOSED_HEADER]: `true`,
4828
+ [PRODUCER_ID_HEADER]: `test-producer`,
4829
+ [PRODUCER_EPOCH_HEADER]: `0`,
4830
+ [PRODUCER_SEQ_HEADER]: `1`
4831
+ },
4832
+ body: `should fail`
4833
+ });
4834
+ expect(differentSeq.status).toBe(409);
4835
+ expect(differentSeq.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4836
+ });
4837
+ test(`idempotent-close-only-duplicate-returns-204: Duplicate close-only (no body) returns 204`, async () => {
4838
+ const streamPath = `/v1/stream/idempotent-close-only-dup-${Date.now()}`;
4839
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4840
+ method: `PUT`,
4841
+ headers: { "Content-Type": `text/plain` }
4842
+ });
4843
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4844
+ method: `POST`,
4845
+ headers: {
4846
+ [STREAM_CLOSED_HEADER]: `true`,
4847
+ [PRODUCER_ID_HEADER]: `test-producer`,
4848
+ [PRODUCER_EPOCH_HEADER]: `0`,
4849
+ [PRODUCER_SEQ_HEADER]: `0`
4850
+ }
4851
+ });
4852
+ expect(firstClose.status).toBe(204);
4853
+ const duplicateClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4854
+ method: `POST`,
4855
+ headers: {
4856
+ [STREAM_CLOSED_HEADER]: `true`,
4857
+ [PRODUCER_ID_HEADER]: `test-producer`,
4858
+ [PRODUCER_EPOCH_HEADER]: `0`,
4859
+ [PRODUCER_SEQ_HEADER]: `0`
4860
+ }
4861
+ });
4862
+ expect(duplicateClose.status).toBe(204);
4863
+ expect(duplicateClose.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4864
+ });
4865
+ });
4866
+ describe(`Edge Cases`, () => {
4867
+ const PRODUCER_ID_HEADER = `Producer-Id`;
4868
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`;
4869
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`;
4870
+ test(`409-includes-stream-offset: 409 for closed stream includes Stream-Next-Offset header`, async () => {
4871
+ const streamPath = `/v1/stream/409-offset-${Date.now()}`;
4872
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4873
+ method: `PUT`,
4874
+ headers: { "Content-Type": `text/plain` },
4875
+ body: `some content`
4876
+ });
4877
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4878
+ method: `POST`,
4879
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4880
+ });
4881
+ const finalOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER);
4882
+ expect(finalOffset).toBeTruthy();
4883
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4884
+ method: `POST`,
4885
+ headers: { "Content-Type": `text/plain` },
4886
+ body: `should fail`
4887
+ });
4888
+ expect(appendResponse.status).toBe(409);
4889
+ expect(appendResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4890
+ expect(appendResponse.headers.get(STREAM_OFFSET_HEADER)).toBe(finalOffset);
4891
+ });
4892
+ test(`close-nonexistent-stream-404: POST with Stream-Closed to nonexistent stream returns 404`, async () => {
4893
+ const streamPath = `/v1/stream/nonexistent-close-${Date.now()}`;
4894
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4895
+ method: `POST`,
4896
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4897
+ });
4898
+ expect(closeResponse.status).toBe(404);
4899
+ });
4900
+ test(`offset-now-on-closed-stream: offset=now on closed stream returns Stream-Closed: true`, async () => {
4901
+ const streamPath = `/v1/stream/offset-now-closed-${Date.now()}`;
4902
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4903
+ method: `PUT`,
4904
+ headers: { "Content-Type": `text/plain` },
4905
+ body: `content`
4906
+ });
4907
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4908
+ method: `POST`,
4909
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
4910
+ });
4911
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}?offset=now`);
4912
+ expect(readResponse.status).toBe(200);
4913
+ expect(readResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
4914
+ expect(readResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
4915
+ });
4916
+ test(`producer-state-survives-close: Stale-epoch producer gets 403, not 409 STREAM_CLOSED`, async () => {
4917
+ const streamPath = `/v1/stream/producer-state-close-${Date.now()}`;
4918
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4919
+ method: `PUT`,
4920
+ headers: { "Content-Type": `text/plain` }
4921
+ });
4922
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4923
+ method: `POST`,
4924
+ headers: {
4925
+ "Content-Type": `text/plain`,
4926
+ [PRODUCER_ID_HEADER]: `producer-A`,
4927
+ [PRODUCER_EPOCH_HEADER]: `0`,
4928
+ [PRODUCER_SEQ_HEADER]: `0`
4929
+ },
4930
+ body: `first`
4931
+ });
4932
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4933
+ method: `POST`,
4934
+ headers: {
4935
+ "Content-Type": `text/plain`,
4936
+ [STREAM_CLOSED_HEADER]: `true`,
4937
+ [PRODUCER_ID_HEADER]: `producer-A`,
4938
+ [PRODUCER_EPOCH_HEADER]: `1`,
4939
+ [PRODUCER_SEQ_HEADER]: `0`
4940
+ },
4941
+ body: `final`
4942
+ });
4943
+ const staleResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
4944
+ method: `POST`,
4945
+ headers: {
4946
+ "Content-Type": `text/plain`,
4947
+ [STREAM_CLOSED_HEADER]: `true`,
4948
+ [PRODUCER_ID_HEADER]: `producer-A`,
4949
+ [PRODUCER_EPOCH_HEADER]: `0`,
4950
+ [PRODUCER_SEQ_HEADER]: `1`
4951
+ },
4952
+ body: `stale attempt`
4953
+ });
4954
+ expect([403, 409]).toContain(staleResponse.status);
4955
+ });
4956
+ test(`close-with-different-body-dedup: Retry close with different body deduplicates to original`, async () => {
4957
+ const streamPath = `/v1/stream/close-dedup-body-${Date.now()}`;
4958
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4959
+ method: `PUT`,
4960
+ headers: { "Content-Type": `text/plain` }
4961
+ });
4962
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4963
+ method: `POST`,
4964
+ headers: {
4965
+ "Content-Type": `text/plain`,
4966
+ [STREAM_CLOSED_HEADER]: `true`,
4967
+ [PRODUCER_ID_HEADER]: `test-producer`,
4968
+ [PRODUCER_EPOCH_HEADER]: `0`,
4969
+ [PRODUCER_SEQ_HEADER]: `0`
4970
+ },
4971
+ body: `body-A`
4972
+ });
4973
+ expect(firstClose.status).toBe(200);
4974
+ const retryClose = await fetch(`${getBaseUrl()}${streamPath}`, {
4975
+ method: `POST`,
4976
+ headers: {
4977
+ "Content-Type": `text/plain`,
4978
+ [STREAM_CLOSED_HEADER]: `true`,
4979
+ [PRODUCER_ID_HEADER]: `test-producer`,
4980
+ [PRODUCER_EPOCH_HEADER]: `0`,
4981
+ [PRODUCER_SEQ_HEADER]: `0`
4982
+ },
4983
+ body: `body-B`
4984
+ });
4985
+ expect(retryClose.status).toBe(204);
4986
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
4987
+ const content = await readResponse.text();
4988
+ expect(content).toBe(`body-A`);
4989
+ });
4990
+ test(`empty-post-without-stream-closed-400: POST with empty body but no Stream-Closed returns 400`, async () => {
4991
+ const streamPath = `/v1/stream/empty-no-closed-${Date.now()}`;
4992
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4993
+ method: `PUT`,
4994
+ headers: { "Content-Type": `text/plain` }
4995
+ });
4996
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
4997
+ method: `POST`,
4998
+ headers: { "Content-Type": `text/plain` },
4999
+ body: ``
5000
+ });
5001
+ expect(response.status).toBe(400);
5002
+ });
5003
+ test(`delete-closed-stream: Deleting a closed stream removes it (returns 404 after)`, async () => {
5004
+ const streamPath = `/v1/stream/delete-closed-${Date.now()}`;
5005
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5006
+ method: `PUT`,
5007
+ headers: { "Content-Type": `text/plain` },
5008
+ body: `content`
5009
+ });
5010
+ await fetch(`${getBaseUrl()}${streamPath}`, {
5011
+ method: `POST`,
5012
+ headers: { [STREAM_CLOSED_HEADER]: `true` }
5013
+ });
5014
+ const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
5015
+ expect(headBefore.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`);
5016
+ const deleteResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `DELETE` });
5017
+ expect([200, 204]).toContain(deleteResponse.status);
5018
+ const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
5019
+ expect(headAfter.status).toBe(404);
5020
+ });
5021
+ });
5022
+ });
4081
5023
  }
4082
5024
 
4083
5025
  //#endregion