@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.
package/src/index.ts CHANGED
@@ -3153,22 +3153,6 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3153
3153
  expect(response.status).toBe(400)
3154
3154
  })
3155
3155
 
3156
- test(`client should reject SSE mode for incompatible content types`, async () => {
3157
- const streamPath = `/v1/stream/sse-binary-test-${Date.now()}`
3158
-
3159
- // Create stream with binary content type (not SSE compatible)
3160
- const stream = await DurableStream.create({
3161
- url: `${getBaseUrl()}${streamPath}`,
3162
- contentType: `application/octet-stream`,
3163
- })
3164
-
3165
- // Append some binary data
3166
- await stream.append(new Uint8Array([0x01, 0x02, 0x03]))
3167
-
3168
- // Trying to read via SSE mode should throw
3169
- await expect(stream.stream({ live: `sse` })).rejects.toThrow()
3170
- })
3171
-
3172
3156
  test(`should stream data events via SSE`, async () => {
3173
3157
  const streamPath = `/v1/stream/sse-data-stream-test-${Date.now()}`
3174
3158
 
@@ -3649,19 +3633,23 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3649
3633
  })
3650
3634
 
3651
3635
  // First SSE connection - get initial data and offset
3636
+ // Wait for upToDate to ensure we receive all messages (control events
3637
+ // are sent after each data event, so we need the last one with upToDate)
3652
3638
  let lastOffset: string | null = null
3653
3639
  const { response: response1, received: received1 } = await fetchSSE(
3654
3640
  `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3655
- { untilContent: `event: control` }
3641
+ { untilContent: `upToDate` }
3656
3642
  )
3657
3643
 
3658
3644
  expect(response1.status).toBe(200)
3659
3645
 
3660
- // Extract offset from control event
3661
- const controlLine = received1
3646
+ // Extract offset from the LAST control event (with upToDate)
3647
+ // Control events are sent after each data event per protocol section 5.7
3648
+ const controlLines = received1
3662
3649
  .split(`\n`)
3663
- .find((l) => l.startsWith(`data:`) && l.includes(`streamNextOffset`))
3664
- const controlPayload = controlLine!.slice(`data:`.length)
3650
+ .filter((l) => l.startsWith(`data:`) && l.includes(`streamNextOffset`))
3651
+ const lastControlLine = controlLines[controlLines.length - 1]
3652
+ const controlPayload = lastControlLine!.slice(`data:`.length)
3665
3653
  lastOffset = JSON.parse(controlPayload)[`streamNextOffset`]
3666
3654
 
3667
3655
  expect(lastOffset).toBeDefined()
@@ -3687,6 +3675,344 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3687
3675
  expect(received2).not.toContain(`message 1`)
3688
3676
  expect(received2).not.toContain(`message 2`)
3689
3677
  })
3678
+
3679
+ // ==========================================================================
3680
+ // Base64 Encoding for Binary Streams (Protocol Section 5.7)
3681
+ // ==========================================================================
3682
+
3683
+ test(`should auto-detect binary streams and return base64 encoded data in SSE mode`, async () => {
3684
+ const streamPath = `/v1/stream/sse-binary-base64-${Date.now()}`
3685
+
3686
+ // Create stream with binary content type and known binary data
3687
+ const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) // "Hello" in ASCII
3688
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3689
+ method: `PUT`,
3690
+ headers: { "Content-Type": `application/octet-stream` },
3691
+ body: binaryData,
3692
+ })
3693
+
3694
+ // SSE request for binary stream should auto-detect and use base64
3695
+ const { response, received } = await fetchSSE(
3696
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3697
+ { untilContent: `event: control` }
3698
+ )
3699
+
3700
+ expect(response.status).toBe(200)
3701
+ expect(response.headers.get(`content-type`)).toBe(`text/event-stream`)
3702
+
3703
+ // Parse events
3704
+ const events = parseSSEEvents(received)
3705
+ const dataEvents = events.filter((e) => e.type === `data`)
3706
+ const controlEvents = events.filter((e) => e.type === `control`)
3707
+
3708
+ expect(dataEvents.length).toBe(1)
3709
+ expect(controlEvents.length).toBe(1)
3710
+
3711
+ // Data should be base64 encoded - "Hello" -> "SGVsbG8="
3712
+ // Remove any whitespace that might be in the base64 string
3713
+ const base64Data = dataEvents[0]!.data.replace(/[\n\r\s]/g, ``)
3714
+ expect(base64Data).toBe(`SGVsbG8=`)
3715
+
3716
+ // Control event should still be valid JSON (not base64 encoded)
3717
+ const controlData = JSON.parse(controlEvents[0]!.data)
3718
+ expect(controlData.streamNextOffset).toBeDefined()
3719
+ })
3720
+
3721
+ test(`should include Stream-SSE-Data-Encoding header for binary streams`, async () => {
3722
+ const streamPath = `/v1/stream/sse-encoding-header-${Date.now()}`
3723
+
3724
+ // Create stream with binary content type
3725
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3726
+ method: `PUT`,
3727
+ headers: { "Content-Type": `application/octet-stream` },
3728
+ body: new Uint8Array([0x01, 0x02, 0x03]),
3729
+ })
3730
+
3731
+ // SSE request for binary stream (server auto-detects encoding)
3732
+ const { response } = await fetchSSE(
3733
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3734
+ { untilContent: `event: control` }
3735
+ )
3736
+
3737
+ expect(response.status).toBe(200)
3738
+
3739
+ // Should include the Stream-SSE-Data-Encoding header
3740
+ const encodingHeader = response.headers.get(`stream-sse-data-encoding`)
3741
+ expect(encodingHeader).toBe(`base64`)
3742
+ })
3743
+
3744
+ test(`should NOT include Stream-SSE-Data-Encoding header for text/plain streams`, async () => {
3745
+ const streamPath = `/v1/stream/sse-text-no-encoding-${Date.now()}`
3746
+
3747
+ // Create stream with text content type
3748
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3749
+ method: `PUT`,
3750
+ headers: { "Content-Type": `text/plain` },
3751
+ body: `hello world`,
3752
+ })
3753
+
3754
+ const { response } = await fetchSSE(
3755
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3756
+ { untilContent: `event: control` }
3757
+ )
3758
+
3759
+ expect(response.status).toBe(200)
3760
+
3761
+ // Should NOT include the Stream-SSE-Data-Encoding header for text streams
3762
+ const encodingHeader = response.headers.get(`stream-sse-data-encoding`)
3763
+ expect(encodingHeader).toBeNull()
3764
+ })
3765
+
3766
+ test(`should NOT include Stream-SSE-Data-Encoding header for application/json streams`, async () => {
3767
+ const streamPath = `/v1/stream/sse-json-no-encoding-${Date.now()}`
3768
+
3769
+ // Create stream with JSON content type
3770
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3771
+ method: `PUT`,
3772
+ headers: { "Content-Type": `application/json` },
3773
+ body: JSON.stringify({ message: `hello` }),
3774
+ })
3775
+
3776
+ const { response } = await fetchSSE(
3777
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3778
+ { untilContent: `event: control` }
3779
+ )
3780
+
3781
+ expect(response.status).toBe(200)
3782
+
3783
+ // Should NOT include the Stream-SSE-Data-Encoding header for JSON streams
3784
+ const encodingHeader = response.headers.get(`stream-sse-data-encoding`)
3785
+ expect(encodingHeader).toBeNull()
3786
+ })
3787
+
3788
+ test(`should base64 encode data events only, control events remain JSON`, async () => {
3789
+ const streamPath = `/v1/stream/sse-base64-data-only-${Date.now()}`
3790
+
3791
+ // Create stream with binary content type
3792
+ const binaryData = new Uint8Array([0xff, 0xfe, 0xfd])
3793
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3794
+ method: `PUT`,
3795
+ headers: { "Content-Type": `application/octet-stream` },
3796
+ body: binaryData,
3797
+ })
3798
+
3799
+ const { response, received } = await fetchSSE(
3800
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3801
+ { untilContent: `event: control` }
3802
+ )
3803
+
3804
+ expect(response.status).toBe(200)
3805
+
3806
+ const events = parseSSEEvents(received)
3807
+ const controlEvents = events.filter((e) => e.type === `control`)
3808
+
3809
+ expect(controlEvents.length).toBe(1)
3810
+
3811
+ // Control event should be valid JSON with proper fields
3812
+ const controlData = JSON.parse(controlEvents[0]!.data)
3813
+ expect(controlData.streamNextOffset).toBeDefined()
3814
+ expect(typeof controlData.streamNextOffset).toBe(`string`)
3815
+ expect(controlData.streamCursor).toBeDefined()
3816
+ })
3817
+
3818
+ test(`should handle empty binary payload with auto-detected base64 encoding`, async () => {
3819
+ const streamPath = `/v1/stream/sse-base64-empty-${Date.now()}`
3820
+
3821
+ // Create empty stream with binary content type
3822
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3823
+ method: `PUT`,
3824
+ headers: { "Content-Type": `application/octet-stream` },
3825
+ })
3826
+
3827
+ const { response, received } = await fetchSSE(
3828
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3829
+ { untilContent: `event: control` }
3830
+ )
3831
+
3832
+ expect(response.status).toBe(200)
3833
+
3834
+ // Should receive a control event indicating up-to-date
3835
+ const events = parseSSEEvents(received)
3836
+ const controlEvents = events.filter((e) => e.type === `control`)
3837
+
3838
+ expect(controlEvents.length).toBeGreaterThanOrEqual(1)
3839
+
3840
+ const controlData = JSON.parse(controlEvents[0]!.data)
3841
+ expect(controlData.upToDate).toBe(true)
3842
+ })
3843
+
3844
+ test(`should handle large binary payload with auto-detected base64 encoding`, async () => {
3845
+ const streamPath = `/v1/stream/sse-base64-large-${Date.now()}`
3846
+
3847
+ // Create stream with larger binary data (1KB)
3848
+ const binaryData = new Uint8Array(1024)
3849
+ for (let i = 0; i < 1024; i++) {
3850
+ binaryData[i] = i % 256
3851
+ }
3852
+
3853
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3854
+ method: `PUT`,
3855
+ headers: { "Content-Type": `application/octet-stream` },
3856
+ body: binaryData,
3857
+ })
3858
+
3859
+ const { response, received } = await fetchSSE(
3860
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3861
+ { untilContent: `event: control`, timeoutMs: 5000 }
3862
+ )
3863
+
3864
+ expect(response.status).toBe(200)
3865
+
3866
+ const events = parseSSEEvents(received)
3867
+ const dataEvents = events.filter((e) => e.type === `data`)
3868
+
3869
+ expect(dataEvents.length).toBe(1)
3870
+
3871
+ // Decode and verify the data
3872
+ const base64Data = dataEvents[0]!.data.replace(/[\n\r\s]/g, ``)
3873
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0))
3874
+
3875
+ expect(decoded.length).toBe(1024)
3876
+ for (let i = 0; i < 1024; i++) {
3877
+ expect(decoded[i]).toBe(i % 256)
3878
+ }
3879
+ })
3880
+
3881
+ test(`should handle binary data with special bytes using auto-detected base64 encoding`, async () => {
3882
+ const streamPath = `/v1/stream/sse-base64-special-bytes-${Date.now()}`
3883
+
3884
+ // Binary data that would break SSE if not encoded:
3885
+ // - null bytes, newlines, carriage returns, high bytes
3886
+ const binaryData = new Uint8Array([
3887
+ 0x00, 0x0a, 0x0d, 0xff, 0xfe, 0x00, 0x0a, 0x0d,
3888
+ ])
3889
+
3890
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3891
+ method: `PUT`,
3892
+ headers: { "Content-Type": `application/octet-stream` },
3893
+ body: binaryData,
3894
+ })
3895
+
3896
+ const { response, received } = await fetchSSE(
3897
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3898
+ { untilContent: `event: control` }
3899
+ )
3900
+
3901
+ expect(response.status).toBe(200)
3902
+
3903
+ const events = parseSSEEvents(received)
3904
+ const dataEvents = events.filter((e) => e.type === `data`)
3905
+
3906
+ expect(dataEvents.length).toBe(1)
3907
+
3908
+ // Decode and verify the exact bytes
3909
+ const base64Data = dataEvents[0]!.data.replace(/[\n\r\s]/g, ``)
3910
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0))
3911
+
3912
+ expect(decoded.length).toBe(8)
3913
+ expect(decoded[0]).toBe(0x00) // null byte
3914
+ expect(decoded[1]).toBe(0x0a) // newline
3915
+ expect(decoded[2]).toBe(0x0d) // carriage return
3916
+ expect(decoded[3]).toBe(0xff) // high byte
3917
+ expect(decoded[4]).toBe(0xfe) // high byte
3918
+ })
3919
+
3920
+ test(`should auto-detect base64 encoding for application/x-protobuf streams`, async () => {
3921
+ const streamPath = `/v1/stream/sse-base64-protobuf-${Date.now()}`
3922
+
3923
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3924
+ method: `PUT`,
3925
+ headers: { "Content-Type": `application/x-protobuf` },
3926
+ body: new Uint8Array([0x08, 0x06, 0x12, 0x04, 0x6e, 0x61, 0x6d, 0x65]),
3927
+ })
3928
+
3929
+ const { response, received } = await fetchSSE(
3930
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3931
+ { untilContent: `event: control` }
3932
+ )
3933
+
3934
+ expect(response.status).toBe(200)
3935
+
3936
+ const events = parseSSEEvents(received)
3937
+ const dataEvents = events.filter((e) => e.type === `data`)
3938
+
3939
+ expect(dataEvents.length).toBe(1)
3940
+
3941
+ const base64Data = dataEvents[0]!.data.replace(/[\n\r\s]/g, ``)
3942
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0))
3943
+
3944
+ expect(decoded.length).toBe(8)
3945
+ expect(decoded[0]).toBe(0x08)
3946
+ expect(decoded[7]).toBe(0x65)
3947
+ })
3948
+
3949
+ test(`should auto-detect base64 encoding for image/png streams`, async () => {
3950
+ const streamPath = `/v1/stream/sse-base64-image-${Date.now()}`
3951
+
3952
+ // PNG magic header bytes
3953
+ const pngHeader = new Uint8Array([
3954
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
3955
+ ])
3956
+
3957
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3958
+ method: `PUT`,
3959
+ headers: { "Content-Type": `image/png` },
3960
+ body: pngHeader,
3961
+ })
3962
+
3963
+ const { response, received } = await fetchSSE(
3964
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3965
+ { untilContent: `event: control` }
3966
+ )
3967
+
3968
+ expect(response.status).toBe(200)
3969
+
3970
+ const events = parseSSEEvents(received)
3971
+ const dataEvents = events.filter((e) => e.type === `data`)
3972
+
3973
+ expect(dataEvents.length).toBe(1)
3974
+
3975
+ const base64Data = dataEvents[0]!.data.replace(/[\n\r\s]/g, ``)
3976
+ const decoded = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0))
3977
+
3978
+ expect(decoded.length).toBe(8)
3979
+ expect(decoded[0]).toBe(0x89) // PNG magic byte
3980
+ expect(decoded[1]).toBe(0x50) // 'P'
3981
+ expect(decoded[2]).toBe(0x4e) // 'N'
3982
+ expect(decoded[3]).toBe(0x47) // 'G'
3983
+ })
3984
+
3985
+ test(`should handle offset=now with auto-detected base64 encoding for binary streams`, async () => {
3986
+ const streamPath = `/v1/stream/sse-base64-offset-now-${Date.now()}`
3987
+
3988
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3989
+ method: `PUT`,
3990
+ headers: { "Content-Type": `application/octet-stream` },
3991
+ body: new Uint8Array([0x01, 0x02, 0x03]),
3992
+ })
3993
+
3994
+ const { response, received } = await fetchSSE(
3995
+ `${getBaseUrl()}${streamPath}?offset=now&live=sse`,
3996
+ { untilContent: `"upToDate"` }
3997
+ )
3998
+
3999
+ expect(response.status).toBe(200)
4000
+
4001
+ // Should have control event with upToDate:true
4002
+ const controlMatch = received.match(
4003
+ /event: control\s*\n\s*data:({[^}]+})/
4004
+ )
4005
+ expect(controlMatch).toBeDefined()
4006
+ if (controlMatch && controlMatch[1]) {
4007
+ const controlData = JSON.parse(controlMatch[1])
4008
+ expect(controlData[`upToDate`]).toBe(true)
4009
+ }
4010
+
4011
+ // Should NOT contain historical data events (offset=now skips existing data)
4012
+ const events = parseSSEEvents(received)
4013
+ const dataEvents = events.filter((e) => e.type === `data`)
4014
+ expect(dataEvents.length).toBe(0)
4015
+ })
3690
4016
  })
3691
4017
 
3692
4018
  // ============================================================================
@@ -4413,7 +4739,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4413
4739
  ),
4414
4740
  { numRuns: 25 }
4415
4741
  )
4416
- }, 15000)
4742
+ })
4417
4743
 
4418
4744
  test(`read-your-writes: data is immediately visible after append`, async () => {
4419
4745
  await fc.assert(
@@ -4936,7 +5262,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4936
5262
  ),
4937
5263
  { numRuns: 15 }
4938
5264
  )
4939
- }, 15000)
5265
+ })
4940
5266
 
4941
5267
  test(`content hash changes with each append`, async () => {
4942
5268
  const streamPath = `/v1/stream/hash-changes-${Date.now()}-${Math.random().toString(36).slice(2)}`
@@ -6172,4 +6498,1062 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
6172
6498
  expect(r.status).toBe(400)
6173
6499
  })
6174
6500
  })
6501
+
6502
+ // ============================================================================
6503
+ // Stream Closure
6504
+ // ============================================================================
6505
+
6506
+ describe(`Stream Closure`, () => {
6507
+ // Header constant for Stream-Closed
6508
+ const STREAM_CLOSED_HEADER = `Stream-Closed`
6509
+
6510
+ // ========================================================================
6511
+ // Create Tests
6512
+ // ========================================================================
6513
+
6514
+ describe(`Create with Stream-Closed`, () => {
6515
+ test(`create-closed-stream: PUT with Stream-Closed: true creates closed stream`, async () => {
6516
+ const streamPath = `/v1/stream/create-closed-${Date.now()}`
6517
+
6518
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
6519
+ method: `PUT`,
6520
+ headers: {
6521
+ "Content-Type": `text/plain`,
6522
+ [STREAM_CLOSED_HEADER]: `true`,
6523
+ },
6524
+ })
6525
+
6526
+ expect(response.status).toBe(201)
6527
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6528
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy()
6529
+ })
6530
+
6531
+ test(`create-closed-stream-with-body: PUT with body + Stream-Closed: true`, async () => {
6532
+ const streamPath = `/v1/stream/create-closed-body-${Date.now()}`
6533
+
6534
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
6535
+ method: `PUT`,
6536
+ headers: {
6537
+ "Content-Type": `text/plain`,
6538
+ [STREAM_CLOSED_HEADER]: `true`,
6539
+ },
6540
+ body: `initial content`,
6541
+ })
6542
+
6543
+ expect(response.status).toBe(201)
6544
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6545
+
6546
+ // Verify content is readable
6547
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
6548
+ const content = await readResponse.text()
6549
+ expect(content).toBe(`initial content`)
6550
+ expect(readResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6551
+ })
6552
+
6553
+ test(`create-closed-returns-header: Response includes Stream-Closed: true`, async () => {
6554
+ const streamPath = `/v1/stream/create-closed-header-${Date.now()}`
6555
+
6556
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
6557
+ method: `PUT`,
6558
+ headers: {
6559
+ "Content-Type": `text/plain`,
6560
+ [STREAM_CLOSED_HEADER]: `true`,
6561
+ },
6562
+ })
6563
+
6564
+ expect(response.status).toBe(201)
6565
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6566
+ })
6567
+ })
6568
+
6569
+ // ========================================================================
6570
+ // Close Tests
6571
+ // ========================================================================
6572
+
6573
+ describe(`Close Operations`, () => {
6574
+ test(`close-stream-empty-post: POST with Stream-Closed: true, empty body closes stream`, async () => {
6575
+ const streamPath = `/v1/stream/close-empty-${Date.now()}`
6576
+
6577
+ // Create stream
6578
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6579
+ method: `PUT`,
6580
+ headers: { "Content-Type": `text/plain` },
6581
+ })
6582
+
6583
+ // Close with empty body
6584
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6585
+ method: `POST`,
6586
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6587
+ })
6588
+
6589
+ expect(closeResponse.status).toBe(204)
6590
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6591
+ })
6592
+
6593
+ test(`close-with-final-append: POST with body + Stream-Closed: true`, async () => {
6594
+ const streamPath = `/v1/stream/close-final-${Date.now()}`
6595
+
6596
+ // Create stream
6597
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6598
+ method: `PUT`,
6599
+ headers: { "Content-Type": `text/plain` },
6600
+ })
6601
+
6602
+ // Append some data first
6603
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6604
+ method: `POST`,
6605
+ headers: { "Content-Type": `text/plain` },
6606
+ body: `first message`,
6607
+ })
6608
+
6609
+ // Close with final append
6610
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6611
+ method: `POST`,
6612
+ headers: {
6613
+ "Content-Type": `text/plain`,
6614
+ [STREAM_CLOSED_HEADER]: `true`,
6615
+ },
6616
+ body: `final message`,
6617
+ })
6618
+
6619
+ expect(closeResponse.status).toBe(204)
6620
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6621
+
6622
+ // Verify all content
6623
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
6624
+ const content = await readResponse.text()
6625
+ expect(content).toBe(`first messagefinal message`)
6626
+ })
6627
+
6628
+ test(`close-returns-offset-and-header: Response includes Stream-Next-Offset and Stream-Closed: true`, async () => {
6629
+ const streamPath = `/v1/stream/close-returns-${Date.now()}`
6630
+
6631
+ // Create and append
6632
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6633
+ method: `PUT`,
6634
+ headers: { "Content-Type": `text/plain` },
6635
+ body: `content`,
6636
+ })
6637
+
6638
+ // Close
6639
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6640
+ method: `POST`,
6641
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6642
+ })
6643
+
6644
+ expect(closeResponse.status).toBe(204)
6645
+ expect(closeResponse.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy()
6646
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6647
+ })
6648
+
6649
+ test(`close-idempotent: Closing already-closed stream (empty body) returns 204`, async () => {
6650
+ const streamPath = `/v1/stream/close-idempotent-${Date.now()}`
6651
+
6652
+ // Create stream
6653
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6654
+ method: `PUT`,
6655
+ headers: { "Content-Type": `text/plain` },
6656
+ })
6657
+
6658
+ // First close
6659
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
6660
+ method: `POST`,
6661
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6662
+ })
6663
+ expect(firstClose.status).toBe(204)
6664
+
6665
+ // Second close (should be idempotent)
6666
+ const secondClose = await fetch(`${getBaseUrl()}${streamPath}`, {
6667
+ method: `POST`,
6668
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6669
+ })
6670
+ expect(secondClose.status).toBe(204)
6671
+ expect(secondClose.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6672
+ })
6673
+
6674
+ test(`close-only-ignores-content-type: Close-only with mismatched Content-Type still succeeds`, async () => {
6675
+ const streamPath = `/v1/stream/close-ignores-ct-${Date.now()}`
6676
+
6677
+ // Create JSON stream
6678
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6679
+ method: `PUT`,
6680
+ headers: { "Content-Type": `application/json` },
6681
+ })
6682
+
6683
+ // Close with mismatched Content-Type (should be ignored for empty body)
6684
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6685
+ method: `POST`,
6686
+ headers: {
6687
+ "Content-Type": `text/plain`,
6688
+ [STREAM_CLOSED_HEADER]: `true`,
6689
+ },
6690
+ })
6691
+
6692
+ expect(closeResponse.status).toBe(204)
6693
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6694
+ })
6695
+
6696
+ test(`append-to-closed-stream-409: Append to closed stream returns 409 with Stream-Closed: true header`, async () => {
6697
+ const streamPath = `/v1/stream/append-closed-${Date.now()}`
6698
+
6699
+ // Create and close
6700
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6701
+ method: `PUT`,
6702
+ headers: { "Content-Type": `text/plain` },
6703
+ })
6704
+
6705
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6706
+ method: `POST`,
6707
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6708
+ })
6709
+
6710
+ // Try to append
6711
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6712
+ method: `POST`,
6713
+ headers: { "Content-Type": `text/plain` },
6714
+ body: `should fail`,
6715
+ })
6716
+
6717
+ expect(appendResponse.status).toBe(409)
6718
+ expect(appendResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6719
+ })
6720
+
6721
+ test(`append-and-close-to-closed-stream-409: POST with body + Stream-Closed: true to already-closed stream returns 409`, async () => {
6722
+ const streamPath = `/v1/stream/append-close-closed-${Date.now()}`
6723
+
6724
+ // Create and close
6725
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6726
+ method: `PUT`,
6727
+ headers: { "Content-Type": `text/plain` },
6728
+ })
6729
+
6730
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6731
+ method: `POST`,
6732
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6733
+ })
6734
+
6735
+ // Try to append-and-close (without producer headers, so not idempotent)
6736
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
6737
+ method: `POST`,
6738
+ headers: {
6739
+ "Content-Type": `text/plain`,
6740
+ [STREAM_CLOSED_HEADER]: `true`,
6741
+ },
6742
+ body: `should fail`,
6743
+ })
6744
+
6745
+ expect(response.status).toBe(409)
6746
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6747
+ })
6748
+ })
6749
+
6750
+ // ========================================================================
6751
+ // HEAD Tests
6752
+ // ========================================================================
6753
+
6754
+ describe(`HEAD with Stream Closure`, () => {
6755
+ test(`head-closed-stream: HEAD returns Stream-Closed: true header`, async () => {
6756
+ const streamPath = `/v1/stream/head-closed-${Date.now()}`
6757
+
6758
+ // Create and close
6759
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6760
+ method: `PUT`,
6761
+ headers: { "Content-Type": `text/plain` },
6762
+ })
6763
+
6764
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6765
+ method: `POST`,
6766
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6767
+ })
6768
+
6769
+ // HEAD should show closed
6770
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6771
+ method: `HEAD`,
6772
+ })
6773
+
6774
+ expect(headResponse.status).toBe(200)
6775
+ expect(headResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6776
+ })
6777
+
6778
+ test(`head-open-stream-no-closed-header: HEAD on open stream does NOT have Stream-Closed header`, async () => {
6779
+ const streamPath = `/v1/stream/head-open-${Date.now()}`
6780
+
6781
+ // Create stream (don't close it)
6782
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6783
+ method: `PUT`,
6784
+ headers: { "Content-Type": `text/plain` },
6785
+ })
6786
+
6787
+ // HEAD should NOT have Stream-Closed header
6788
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6789
+ method: `HEAD`,
6790
+ })
6791
+
6792
+ expect(headResponse.status).toBe(200)
6793
+ expect(headResponse.headers.get(STREAM_CLOSED_HEADER)).toBeNull()
6794
+ })
6795
+ })
6796
+
6797
+ // ========================================================================
6798
+ // Read Tests (Catch-up)
6799
+ // ========================================================================
6800
+
6801
+ describe(`Read Closed Streams (Catch-up)`, () => {
6802
+ test(`read-closed-stream-at-tail: Returns Stream-Closed: true at tail of closed stream`, async () => {
6803
+ const streamPath = `/v1/stream/read-closed-tail-${Date.now()}`
6804
+
6805
+ // Create with content and close
6806
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6807
+ method: `PUT`,
6808
+ headers: { "Content-Type": `text/plain` },
6809
+ body: `content`,
6810
+ })
6811
+
6812
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6813
+ method: `POST`,
6814
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6815
+ })
6816
+
6817
+ // Read from beginning - should get content and Stream-Closed
6818
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
6819
+ expect(readResponse.status).toBe(200)
6820
+ expect(await readResponse.text()).toBe(`content`)
6821
+ expect(readResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6822
+ expect(readResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
6823
+ })
6824
+
6825
+ test(`read-closed-stream-partial-no-closed: Partial read of closed stream does NOT include Stream-Closed`, async () => {
6826
+ const streamPath = `/v1/stream/read-closed-partial-${Date.now()}`
6827
+
6828
+ // Create with initial content
6829
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6830
+ method: `PUT`,
6831
+ headers: { "Content-Type": `text/plain` },
6832
+ body: `first`,
6833
+ })
6834
+
6835
+ // Append more content
6836
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6837
+ method: `POST`,
6838
+ headers: { "Content-Type": `text/plain` },
6839
+ body: `second`,
6840
+ })
6841
+ const secondOffset = appendResponse.headers.get(STREAM_OFFSET_HEADER)
6842
+
6843
+ // Close
6844
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6845
+ method: `POST`,
6846
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6847
+ })
6848
+
6849
+ // Read from beginning but stop before tail
6850
+ const partialRead = await fetch(
6851
+ `${getBaseUrl()}${streamPath}?offset=-1`
6852
+ )
6853
+ const partialContent = await partialRead.text()
6854
+ const nextOffset = partialRead.headers.get(STREAM_OFFSET_HEADER)
6855
+
6856
+ // If server returns all data at once, we're at tail and should see Stream-Closed
6857
+ // If server chunks and we haven't reached tail, we should NOT see Stream-Closed
6858
+ if (nextOffset === secondOffset) {
6859
+ // We're at tail - should have Stream-Closed
6860
+ expect(partialRead.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6861
+ } else if (
6862
+ partialContent.length < `firstsecond`.length &&
6863
+ partialContent.length > 0
6864
+ ) {
6865
+ // Partial read - should NOT have Stream-Closed
6866
+ expect(partialRead.headers.get(STREAM_CLOSED_HEADER)).toBeNull()
6867
+ }
6868
+ // If we got all content, Stream-Closed should be true
6869
+ if (partialContent === `firstsecond`) {
6870
+ expect(partialRead.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6871
+ }
6872
+ })
6873
+
6874
+ test(`read-closed-stream-empty-body-eof: At tail of closed stream: 200 OK, empty body, Stream-Closed: true`, async () => {
6875
+ const streamPath = `/v1/stream/read-closed-eof-${Date.now()}`
6876
+
6877
+ // Create with content
6878
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6879
+ method: `PUT`,
6880
+ headers: { "Content-Type": `text/plain` },
6881
+ body: `content`,
6882
+ })
6883
+
6884
+ // Close
6885
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6886
+ method: `POST`,
6887
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6888
+ })
6889
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER)
6890
+
6891
+ // Read at tail offset - should get empty body with Stream-Closed
6892
+ const eofRead = await fetch(
6893
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}`
6894
+ )
6895
+
6896
+ expect(eofRead.status).toBe(200)
6897
+ expect(await eofRead.text()).toBe(``)
6898
+ expect(eofRead.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6899
+ expect(eofRead.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
6900
+ })
6901
+ })
6902
+
6903
+ // ========================================================================
6904
+ // Long-poll Tests
6905
+ // ========================================================================
6906
+
6907
+ describe(`Long-poll with Stream Closure`, () => {
6908
+ test(
6909
+ `longpoll-closed-stream-immediate: No wait when closed stream at tail, returns immediately`,
6910
+ async () => {
6911
+ const streamPath = `/v1/stream/longpoll-closed-${Date.now()}`
6912
+
6913
+ // Create and close
6914
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6915
+ method: `PUT`,
6916
+ headers: { "Content-Type": `text/plain` },
6917
+ })
6918
+
6919
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6920
+ method: `POST`,
6921
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6922
+ })
6923
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER)
6924
+
6925
+ // Long-poll at tail - should return immediately, not wait
6926
+ const startTime = Date.now()
6927
+ const longpollResponse = await fetch(
6928
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`
6929
+ )
6930
+ const elapsed = Date.now() - startTime
6931
+
6932
+ expect(longpollResponse.status).toBe(204)
6933
+ expect(longpollResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(
6934
+ `true`
6935
+ )
6936
+ // Should return almost immediately (not wait the full timeout)
6937
+ expect(elapsed).toBeLessThan(5000)
6938
+ },
6939
+ getLongPollTestTimeoutMs()
6940
+ )
6941
+
6942
+ test(
6943
+ `longpoll-closed-returns-204-with-header: Returns 204 with Stream-Closed: true`,
6944
+ async () => {
6945
+ const streamPath = `/v1/stream/longpoll-closed-204-${Date.now()}`
6946
+
6947
+ // Create with content and close
6948
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6949
+ method: `PUT`,
6950
+ headers: { "Content-Type": `text/plain` },
6951
+ body: `data`,
6952
+ })
6953
+
6954
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6955
+ method: `POST`,
6956
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6957
+ })
6958
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER)
6959
+
6960
+ // Long-poll at tail
6961
+ const response = await fetch(
6962
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`
6963
+ )
6964
+
6965
+ expect(response.status).toBe(204)
6966
+ expect(response.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
6967
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
6968
+ expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeTruthy()
6969
+ },
6970
+ getLongPollTestTimeoutMs()
6971
+ )
6972
+ })
6973
+
6974
+ // ========================================================================
6975
+ // SSE Tests
6976
+ // ========================================================================
6977
+
6978
+ describe(`SSE with Stream Closure`, () => {
6979
+ test(`sse-closed-stream-control-event: Final control event has streamClosed: true`, async () => {
6980
+ const streamPath = `/v1/stream/sse-closed-control-${Date.now()}`
6981
+
6982
+ // Create with content
6983
+ await fetch(`${getBaseUrl()}${streamPath}`, {
6984
+ method: `PUT`,
6985
+ headers: { "Content-Type": `text/plain` },
6986
+ body: `content`,
6987
+ })
6988
+
6989
+ // Close
6990
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
6991
+ method: `POST`,
6992
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
6993
+ })
6994
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER)
6995
+
6996
+ // SSE at tail
6997
+ const { received } = await fetchSSE(
6998
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=sse`,
6999
+ { timeoutMs: 5000, untilContent: `streamClosed` }
7000
+ )
7001
+
7002
+ const events = parseSSEEvents(received)
7003
+ const controlEvents = events.filter((e) => e.type === `control`)
7004
+
7005
+ // Should have a control event with streamClosed: true
7006
+ expect(controlEvents.length).toBeGreaterThan(0)
7007
+ const lastControl = controlEvents[controlEvents.length - 1]!
7008
+ const controlData = JSON.parse(lastControl.data)
7009
+ expect(controlData.streamClosed).toBe(true)
7010
+ })
7011
+
7012
+ test(`sse-closed-stream-no-cursor: streamCursor omitted when streamClosed is true`, async () => {
7013
+ const streamPath = `/v1/stream/sse-closed-no-cursor-${Date.now()}`
7014
+
7015
+ // Create and close
7016
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7017
+ method: `PUT`,
7018
+ headers: { "Content-Type": `text/plain` },
7019
+ })
7020
+
7021
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7022
+ method: `POST`,
7023
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
7024
+ })
7025
+ const tailOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER)
7026
+
7027
+ // SSE at tail
7028
+ const { received } = await fetchSSE(
7029
+ `${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=sse`,
7030
+ { timeoutMs: 5000, untilContent: `streamClosed` }
7031
+ )
7032
+
7033
+ const events = parseSSEEvents(received)
7034
+ const controlEvents = events.filter((e) => e.type === `control`)
7035
+
7036
+ expect(controlEvents.length).toBeGreaterThan(0)
7037
+ const lastControl = controlEvents[controlEvents.length - 1]!
7038
+ const controlData = JSON.parse(lastControl.data)
7039
+
7040
+ expect(controlData.streamClosed).toBe(true)
7041
+ // streamCursor should be omitted when streamClosed is true
7042
+ expect(controlData.streamCursor).toBeUndefined()
7043
+ })
7044
+
7045
+ test(`sse-closed-stream-connection-closes: Connection closes after final event`, async () => {
7046
+ const streamPath = `/v1/stream/sse-closed-conn-${Date.now()}`
7047
+
7048
+ // Create with content and close
7049
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7050
+ method: `PUT`,
7051
+ headers: { "Content-Type": `text/plain` },
7052
+ body: `data`,
7053
+ })
7054
+
7055
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7056
+ method: `POST`,
7057
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
7058
+ })
7059
+
7060
+ // Start SSE from beginning - should receive data, then close
7061
+ const controller = new AbortController()
7062
+ const startTime = Date.now()
7063
+
7064
+ try {
7065
+ const response = await fetch(
7066
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
7067
+ { signal: controller.signal }
7068
+ )
7069
+
7070
+ if (response.body) {
7071
+ const reader = response.body.getReader()
7072
+ let received = ``
7073
+ let chunkCount = 0
7074
+
7075
+ // Read until connection closes
7076
+ while (chunkCount < 20) {
7077
+ const { done, value } = await reader.read()
7078
+ if (done) break
7079
+ received += new TextDecoder().decode(value)
7080
+ chunkCount++
7081
+ }
7082
+
7083
+ // Should have received streamClosed control event
7084
+ expect(received).toContain(`streamClosed`)
7085
+ }
7086
+ } catch {
7087
+ // Connection closing is expected
7088
+ } finally {
7089
+ controller.abort()
7090
+ }
7091
+
7092
+ const elapsed = Date.now() - startTime
7093
+ // Connection should close quickly, not wait for timeout
7094
+ expect(elapsed).toBeLessThan(10000)
7095
+ })
7096
+ })
7097
+
7098
+ // ========================================================================
7099
+ // Idempotent Producer Tests
7100
+ // ========================================================================
7101
+
7102
+ describe(`Idempotent Producers with Stream Closure`, () => {
7103
+ const PRODUCER_ID_HEADER = `Producer-Id`
7104
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`
7105
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`
7106
+
7107
+ test(`idempotent-close-with-append: Close with final append using producer headers`, async () => {
7108
+ const streamPath = `/v1/stream/idempotent-close-append-${Date.now()}`
7109
+
7110
+ // Create stream
7111
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7112
+ method: `PUT`,
7113
+ headers: { "Content-Type": `text/plain` },
7114
+ })
7115
+
7116
+ // Close with final append using producer headers
7117
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7118
+ method: `POST`,
7119
+ headers: {
7120
+ "Content-Type": `text/plain`,
7121
+ [STREAM_CLOSED_HEADER]: `true`,
7122
+ [PRODUCER_ID_HEADER]: `test-producer`,
7123
+ [PRODUCER_EPOCH_HEADER]: `0`,
7124
+ [PRODUCER_SEQ_HEADER]: `0`,
7125
+ },
7126
+ body: `final message`,
7127
+ })
7128
+
7129
+ expect(closeResponse.status).toBe(200)
7130
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7131
+ expect(closeResponse.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`0`)
7132
+ expect(closeResponse.headers.get(PRODUCER_SEQ_HEADER)).toBe(`0`)
7133
+
7134
+ // Verify content
7135
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
7136
+ expect(await readResponse.text()).toBe(`final message`)
7137
+ })
7138
+
7139
+ test(`idempotent-close-only-with-producer-headers: Close-only with producer headers updates state`, async () => {
7140
+ const streamPath = `/v1/stream/idempotent-close-only-${Date.now()}`
7141
+
7142
+ // Create stream
7143
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7144
+ method: `PUT`,
7145
+ headers: { "Content-Type": `text/plain` },
7146
+ })
7147
+
7148
+ // Append first message
7149
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7150
+ method: `POST`,
7151
+ headers: {
7152
+ "Content-Type": `text/plain`,
7153
+ [PRODUCER_ID_HEADER]: `test-producer`,
7154
+ [PRODUCER_EPOCH_HEADER]: `0`,
7155
+ [PRODUCER_SEQ_HEADER]: `0`,
7156
+ },
7157
+ body: `message`,
7158
+ })
7159
+
7160
+ // Close-only with producer headers (seq=1)
7161
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7162
+ method: `POST`,
7163
+ headers: {
7164
+ [STREAM_CLOSED_HEADER]: `true`,
7165
+ [PRODUCER_ID_HEADER]: `test-producer`,
7166
+ [PRODUCER_EPOCH_HEADER]: `0`,
7167
+ [PRODUCER_SEQ_HEADER]: `1`,
7168
+ },
7169
+ })
7170
+
7171
+ expect(closeResponse.status).toBe(204)
7172
+ expect(closeResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7173
+ expect(closeResponse.headers.get(PRODUCER_EPOCH_HEADER)).toBe(`0`)
7174
+ expect(closeResponse.headers.get(PRODUCER_SEQ_HEADER)).toBe(`1`)
7175
+ })
7176
+
7177
+ test(`idempotent-close-duplicate-returns-204: Duplicate close (same tuple) returns 204`, async () => {
7178
+ const streamPath = `/v1/stream/idempotent-close-dup-${Date.now()}`
7179
+
7180
+ // Create stream
7181
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7182
+ method: `PUT`,
7183
+ headers: { "Content-Type": `text/plain` },
7184
+ })
7185
+
7186
+ // First close with producer headers
7187
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
7188
+ method: `POST`,
7189
+ headers: {
7190
+ "Content-Type": `text/plain`,
7191
+ [STREAM_CLOSED_HEADER]: `true`,
7192
+ [PRODUCER_ID_HEADER]: `test-producer`,
7193
+ [PRODUCER_EPOCH_HEADER]: `0`,
7194
+ [PRODUCER_SEQ_HEADER]: `0`,
7195
+ },
7196
+ body: `final`,
7197
+ })
7198
+ expect(firstClose.status).toBe(200)
7199
+
7200
+ // Duplicate close with same tuple
7201
+ const duplicateClose = await fetch(`${getBaseUrl()}${streamPath}`, {
7202
+ method: `POST`,
7203
+ headers: {
7204
+ "Content-Type": `text/plain`,
7205
+ [STREAM_CLOSED_HEADER]: `true`,
7206
+ [PRODUCER_ID_HEADER]: `test-producer`,
7207
+ [PRODUCER_EPOCH_HEADER]: `0`,
7208
+ [PRODUCER_SEQ_HEADER]: `0`,
7209
+ },
7210
+ body: `final`,
7211
+ })
7212
+
7213
+ expect(duplicateClose.status).toBe(204)
7214
+ expect(duplicateClose.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7215
+ })
7216
+
7217
+ test(`idempotent-close-different-tuple-returns-409: Different producer/seq gets 409`, async () => {
7218
+ const streamPath = `/v1/stream/idempotent-close-diff-${Date.now()}`
7219
+
7220
+ // Create stream
7221
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7222
+ method: `PUT`,
7223
+ headers: { "Content-Type": `text/plain` },
7224
+ })
7225
+
7226
+ // Close with first producer
7227
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7228
+ method: `POST`,
7229
+ headers: {
7230
+ "Content-Type": `text/plain`,
7231
+ [STREAM_CLOSED_HEADER]: `true`,
7232
+ [PRODUCER_ID_HEADER]: `producer-A`,
7233
+ [PRODUCER_EPOCH_HEADER]: `0`,
7234
+ [PRODUCER_SEQ_HEADER]: `0`,
7235
+ },
7236
+ body: `final`,
7237
+ })
7238
+
7239
+ // Try to close with different producer
7240
+ const differentProducer = await fetch(`${getBaseUrl()}${streamPath}`, {
7241
+ method: `POST`,
7242
+ headers: {
7243
+ "Content-Type": `text/plain`,
7244
+ [STREAM_CLOSED_HEADER]: `true`,
7245
+ [PRODUCER_ID_HEADER]: `producer-B`,
7246
+ [PRODUCER_EPOCH_HEADER]: `0`,
7247
+ [PRODUCER_SEQ_HEADER]: `0`,
7248
+ },
7249
+ body: `should fail`,
7250
+ })
7251
+
7252
+ expect(differentProducer.status).toBe(409)
7253
+ expect(differentProducer.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7254
+ })
7255
+
7256
+ test(`idempotent-close-different-seq-returns-409: Same producer, different seq gets 409`, async () => {
7257
+ const streamPath = `/v1/stream/idempotent-close-diff-seq-${Date.now()}`
7258
+
7259
+ // Create stream
7260
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7261
+ method: `PUT`,
7262
+ headers: { "Content-Type": `text/plain` },
7263
+ })
7264
+
7265
+ // Close with seq=0
7266
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7267
+ method: `POST`,
7268
+ headers: {
7269
+ "Content-Type": `text/plain`,
7270
+ [STREAM_CLOSED_HEADER]: `true`,
7271
+ [PRODUCER_ID_HEADER]: `test-producer`,
7272
+ [PRODUCER_EPOCH_HEADER]: `0`,
7273
+ [PRODUCER_SEQ_HEADER]: `0`,
7274
+ },
7275
+ body: `final`,
7276
+ })
7277
+
7278
+ // Try with seq=1 (different seq)
7279
+ const differentSeq = await fetch(`${getBaseUrl()}${streamPath}`, {
7280
+ method: `POST`,
7281
+ headers: {
7282
+ "Content-Type": `text/plain`,
7283
+ [STREAM_CLOSED_HEADER]: `true`,
7284
+ [PRODUCER_ID_HEADER]: `test-producer`,
7285
+ [PRODUCER_EPOCH_HEADER]: `0`,
7286
+ [PRODUCER_SEQ_HEADER]: `1`,
7287
+ },
7288
+ body: `should fail`,
7289
+ })
7290
+
7291
+ expect(differentSeq.status).toBe(409)
7292
+ expect(differentSeq.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7293
+ })
7294
+
7295
+ test(`idempotent-close-only-duplicate-returns-204: Duplicate close-only (no body) returns 204`, async () => {
7296
+ const streamPath = `/v1/stream/idempotent-close-only-dup-${Date.now()}`
7297
+
7298
+ // Create stream
7299
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7300
+ method: `PUT`,
7301
+ headers: { "Content-Type": `text/plain` },
7302
+ })
7303
+
7304
+ // First close-only with producer headers
7305
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
7306
+ method: `POST`,
7307
+ headers: {
7308
+ [STREAM_CLOSED_HEADER]: `true`,
7309
+ [PRODUCER_ID_HEADER]: `test-producer`,
7310
+ [PRODUCER_EPOCH_HEADER]: `0`,
7311
+ [PRODUCER_SEQ_HEADER]: `0`,
7312
+ },
7313
+ })
7314
+ expect(firstClose.status).toBe(204)
7315
+
7316
+ // Duplicate close-only with same tuple
7317
+ const duplicateClose = await fetch(`${getBaseUrl()}${streamPath}`, {
7318
+ method: `POST`,
7319
+ headers: {
7320
+ [STREAM_CLOSED_HEADER]: `true`,
7321
+ [PRODUCER_ID_HEADER]: `test-producer`,
7322
+ [PRODUCER_EPOCH_HEADER]: `0`,
7323
+ [PRODUCER_SEQ_HEADER]: `0`,
7324
+ },
7325
+ })
7326
+
7327
+ expect(duplicateClose.status).toBe(204)
7328
+ expect(duplicateClose.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7329
+ })
7330
+ })
7331
+
7332
+ // ========================================================================
7333
+ // Additional Edge Case Tests (from PR review)
7334
+ // ========================================================================
7335
+
7336
+ describe(`Edge Cases`, () => {
7337
+ // Producer header constants for edge case tests
7338
+ const PRODUCER_ID_HEADER = `Producer-Id`
7339
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`
7340
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`
7341
+
7342
+ test(`409-includes-stream-offset: 409 for closed stream includes Stream-Next-Offset header`, async () => {
7343
+ const streamPath = `/v1/stream/409-offset-${Date.now()}`
7344
+
7345
+ // Create stream with content
7346
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7347
+ method: `PUT`,
7348
+ headers: { "Content-Type": `text/plain` },
7349
+ body: `some content`,
7350
+ })
7351
+
7352
+ // Close the stream
7353
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7354
+ method: `POST`,
7355
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
7356
+ })
7357
+ const finalOffset = closeResponse.headers.get(STREAM_OFFSET_HEADER)
7358
+ expect(finalOffset).toBeTruthy()
7359
+
7360
+ // Try to append - should get 409 with offset
7361
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7362
+ method: `POST`,
7363
+ headers: { "Content-Type": `text/plain` },
7364
+ body: `should fail`,
7365
+ })
7366
+
7367
+ expect(appendResponse.status).toBe(409)
7368
+ expect(appendResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7369
+ expect(appendResponse.headers.get(STREAM_OFFSET_HEADER)).toBe(
7370
+ finalOffset
7371
+ )
7372
+ })
7373
+
7374
+ test(`close-nonexistent-stream-404: POST with Stream-Closed to nonexistent stream returns 404`, async () => {
7375
+ const streamPath = `/v1/stream/nonexistent-close-${Date.now()}`
7376
+
7377
+ const closeResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7378
+ method: `POST`,
7379
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
7380
+ })
7381
+
7382
+ expect(closeResponse.status).toBe(404)
7383
+ })
7384
+
7385
+ test(`offset-now-on-closed-stream: offset=now on closed stream returns Stream-Closed: true`, async () => {
7386
+ const streamPath = `/v1/stream/offset-now-closed-${Date.now()}`
7387
+
7388
+ // Create with content and close
7389
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7390
+ method: `PUT`,
7391
+ headers: { "Content-Type": `text/plain` },
7392
+ body: `content`,
7393
+ })
7394
+
7395
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7396
+ method: `POST`,
7397
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
7398
+ })
7399
+
7400
+ // Read with offset=now
7401
+ const readResponse = await fetch(
7402
+ `${getBaseUrl()}${streamPath}?offset=now`
7403
+ )
7404
+
7405
+ expect(readResponse.status).toBe(200)
7406
+ expect(readResponse.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7407
+ expect(readResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
7408
+ })
7409
+
7410
+ test(`producer-state-survives-close: Stale-epoch producer gets 403, not 409 STREAM_CLOSED`, async () => {
7411
+ const streamPath = `/v1/stream/producer-state-close-${Date.now()}`
7412
+
7413
+ // Create stream
7414
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7415
+ method: `PUT`,
7416
+ headers: { "Content-Type": `text/plain` },
7417
+ })
7418
+
7419
+ // Producer A writes with epoch 0
7420
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7421
+ method: `POST`,
7422
+ headers: {
7423
+ "Content-Type": `text/plain`,
7424
+ [PRODUCER_ID_HEADER]: `producer-A`,
7425
+ [PRODUCER_EPOCH_HEADER]: `0`,
7426
+ [PRODUCER_SEQ_HEADER]: `0`,
7427
+ },
7428
+ body: `first`,
7429
+ })
7430
+
7431
+ // Producer A closes with epoch 1 (claiming new epoch)
7432
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7433
+ method: `POST`,
7434
+ headers: {
7435
+ "Content-Type": `text/plain`,
7436
+ [STREAM_CLOSED_HEADER]: `true`,
7437
+ [PRODUCER_ID_HEADER]: `producer-A`,
7438
+ [PRODUCER_EPOCH_HEADER]: `1`,
7439
+ [PRODUCER_SEQ_HEADER]: `0`,
7440
+ },
7441
+ body: `final`,
7442
+ })
7443
+
7444
+ // Producer A with stale epoch 0 tries to close again - should get 403 (stale epoch)
7445
+ const staleResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7446
+ method: `POST`,
7447
+ headers: {
7448
+ "Content-Type": `text/plain`,
7449
+ [STREAM_CLOSED_HEADER]: `true`,
7450
+ [PRODUCER_ID_HEADER]: `producer-A`,
7451
+ [PRODUCER_EPOCH_HEADER]: `0`,
7452
+ [PRODUCER_SEQ_HEADER]: `1`,
7453
+ },
7454
+ body: `stale attempt`,
7455
+ })
7456
+
7457
+ // Should be 403 (stale epoch) - the producer state check happens before stream closed check
7458
+ // This may be 409 depending on implementation order - both are valid
7459
+ expect([403, 409]).toContain(staleResponse.status)
7460
+ })
7461
+
7462
+ test(`close-with-different-body-dedup: Retry close with different body deduplicates to original`, async () => {
7463
+ const streamPath = `/v1/stream/close-dedup-body-${Date.now()}`
7464
+
7465
+ // Create stream
7466
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7467
+ method: `PUT`,
7468
+ headers: { "Content-Type": `text/plain` },
7469
+ })
7470
+
7471
+ // Close with body A
7472
+ const firstClose = await fetch(`${getBaseUrl()}${streamPath}`, {
7473
+ method: `POST`,
7474
+ headers: {
7475
+ "Content-Type": `text/plain`,
7476
+ [STREAM_CLOSED_HEADER]: `true`,
7477
+ [PRODUCER_ID_HEADER]: `test-producer`,
7478
+ [PRODUCER_EPOCH_HEADER]: `0`,
7479
+ [PRODUCER_SEQ_HEADER]: `0`,
7480
+ },
7481
+ body: `body-A`,
7482
+ })
7483
+ expect(firstClose.status).toBe(200)
7484
+
7485
+ // Retry with same tuple but different body
7486
+ const retryClose = await fetch(`${getBaseUrl()}${streamPath}`, {
7487
+ method: `POST`,
7488
+ headers: {
7489
+ "Content-Type": `text/plain`,
7490
+ [STREAM_CLOSED_HEADER]: `true`,
7491
+ [PRODUCER_ID_HEADER]: `test-producer`,
7492
+ [PRODUCER_EPOCH_HEADER]: `0`,
7493
+ [PRODUCER_SEQ_HEADER]: `0`,
7494
+ },
7495
+ body: `body-B`,
7496
+ })
7497
+ expect(retryClose.status).toBe(204) // Duplicate
7498
+
7499
+ // Verify original body is preserved
7500
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
7501
+ const content = await readResponse.text()
7502
+ expect(content).toBe(`body-A`)
7503
+ })
7504
+
7505
+ test(`empty-post-without-stream-closed-400: POST with empty body but no Stream-Closed returns 400`, async () => {
7506
+ const streamPath = `/v1/stream/empty-no-closed-${Date.now()}`
7507
+
7508
+ // Create stream
7509
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7510
+ method: `PUT`,
7511
+ headers: { "Content-Type": `text/plain` },
7512
+ })
7513
+
7514
+ // POST with empty body but no Stream-Closed header
7515
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
7516
+ method: `POST`,
7517
+ headers: { "Content-Type": `text/plain` },
7518
+ body: ``,
7519
+ })
7520
+
7521
+ expect(response.status).toBe(400)
7522
+ })
7523
+
7524
+ test(`delete-closed-stream: Deleting a closed stream removes it (returns 404 after)`, async () => {
7525
+ const streamPath = `/v1/stream/delete-closed-${Date.now()}`
7526
+
7527
+ // Create and close
7528
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7529
+ method: `PUT`,
7530
+ headers: { "Content-Type": `text/plain` },
7531
+ body: `content`,
7532
+ })
7533
+
7534
+ await fetch(`${getBaseUrl()}${streamPath}`, {
7535
+ method: `POST`,
7536
+ headers: { [STREAM_CLOSED_HEADER]: `true` },
7537
+ })
7538
+
7539
+ // Verify closed
7540
+ const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
7541
+ method: `HEAD`,
7542
+ })
7543
+ expect(headBefore.headers.get(STREAM_CLOSED_HEADER)).toBe(`true`)
7544
+
7545
+ // Delete
7546
+ const deleteResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
7547
+ method: `DELETE`,
7548
+ })
7549
+ expect([200, 204]).toContain(deleteResponse.status)
7550
+
7551
+ // Should be 404 now, not 409/STREAM_CLOSED
7552
+ const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
7553
+ method: `HEAD`,
7554
+ })
7555
+ expect(headAfter.status).toBe(404)
7556
+ })
7557
+ })
7558
+ })
6175
7559
  }