@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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/{src-Pd7PHJOG.js → src-D-K9opVc.js} +960 -18
- package/dist/{src-CMFxsR_X.cjs → src-DZatkb9d.cjs} +960 -18
- package/dist/test-runner.cjs +1 -1
- package/dist/test-runner.js +1 -1
- package/package.json +2 -2
- package/src/index.ts +1407 -23
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: `
|
|
3641
|
+
{ untilContent: `upToDate` }
|
|
3656
3642
|
)
|
|
3657
3643
|
|
|
3658
3644
|
expect(response1.status).toBe(200)
|
|
3659
3645
|
|
|
3660
|
-
// Extract offset from control event
|
|
3661
|
-
|
|
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
|
-
.
|
|
3664
|
-
const
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
}
|