@durable-streams/client-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/adapters/typescript-adapter.cjs +109 -33
- package/dist/adapters/typescript-adapter.js +110 -34
- package/dist/{benchmark-runner-DliEfq9k.cjs → benchmark-runner-BQiarXdy.cjs} +80 -2
- package/dist/{benchmark-runner-81waaCzs.js → benchmark-runner-IGT51RTF.js} +80 -2
- package/dist/cli.cjs +2 -2
- package/dist/cli.js +2 -2
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +91 -3
- package/dist/index.d.ts +91 -3
- package/dist/index.js +2 -2
- package/dist/{protocol-BxZTqJmO.d.cts → protocol-9WN0gRRQ.d.ts} +97 -3
- package/dist/{protocol-1p0soayz.js → protocol-BnqUAMKe.js} +1 -0
- package/dist/{protocol-JuFzdV5x.d.ts → protocol-COHkkGmU.d.cts} +97 -3
- package/dist/{protocol-IioVPNaP.cjs → protocol-sDk3deGa.cjs} +1 -0
- package/dist/protocol.cjs +1 -1
- package/dist/protocol.d.cts +2 -2
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.js +1 -1
- package/package.json +3 -3
- package/src/adapters/typescript-adapter.ts +175 -36
- package/src/protocol.ts +108 -0
- package/src/runner.ts +143 -0
- package/src/test-cases.ts +102 -0
- package/test-cases/consumer/message-ordering.yaml +1 -0
- package/test-cases/consumer/offset-handling.yaml +3 -0
- package/test-cases/consumer/read-sse-base64.yaml +663 -0
- package/test-cases/consumer/read-sse.yaml +58 -1
- package/test-cases/consumer/sse-parsing-errors.yaml +4 -0
- package/test-cases/consumer/streaming-equivalence.yaml +6 -0
- package/test-cases/lifecycle/stream-closure.yaml +759 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
id: stream-closure
|
|
2
|
+
name: Stream Closure
|
|
3
|
+
description: Tests for stream closure (EOF) functionality
|
|
4
|
+
category: lifecycle
|
|
5
|
+
tags:
|
|
6
|
+
- core
|
|
7
|
+
- closure
|
|
8
|
+
- eof
|
|
9
|
+
|
|
10
|
+
tests:
|
|
11
|
+
# =============================================================================
|
|
12
|
+
# Writer Tests
|
|
13
|
+
# =============================================================================
|
|
14
|
+
|
|
15
|
+
- id: close-empty-stream
|
|
16
|
+
name: Close stream with no content
|
|
17
|
+
description: Client should be able to close an empty stream
|
|
18
|
+
setup:
|
|
19
|
+
- action: create
|
|
20
|
+
as: streamPath
|
|
21
|
+
contentType: text/plain
|
|
22
|
+
operations:
|
|
23
|
+
- action: close
|
|
24
|
+
path: ${streamPath}
|
|
25
|
+
expect:
|
|
26
|
+
status: 200
|
|
27
|
+
- action: head
|
|
28
|
+
path: ${streamPath}
|
|
29
|
+
expect:
|
|
30
|
+
streamClosed: true
|
|
31
|
+
|
|
32
|
+
- id: close-with-content
|
|
33
|
+
name: Append data then close
|
|
34
|
+
description: Client should be able to append data and then close the stream
|
|
35
|
+
setup:
|
|
36
|
+
- action: create
|
|
37
|
+
as: streamPath
|
|
38
|
+
contentType: application/json
|
|
39
|
+
operations:
|
|
40
|
+
- action: append
|
|
41
|
+
path: ${streamPath}
|
|
42
|
+
data: '{"event": "data"}'
|
|
43
|
+
- action: close
|
|
44
|
+
path: ${streamPath}
|
|
45
|
+
expect:
|
|
46
|
+
status: 200
|
|
47
|
+
- action: head
|
|
48
|
+
path: ${streamPath}
|
|
49
|
+
expect:
|
|
50
|
+
streamClosed: true
|
|
51
|
+
|
|
52
|
+
- id: close-with-final-message
|
|
53
|
+
name: Atomic append and close
|
|
54
|
+
description: Close with a final message should append and close atomically
|
|
55
|
+
setup:
|
|
56
|
+
- action: create
|
|
57
|
+
as: streamPath
|
|
58
|
+
contentType: text/plain
|
|
59
|
+
operations:
|
|
60
|
+
- action: append
|
|
61
|
+
path: ${streamPath}
|
|
62
|
+
data: "initial-data"
|
|
63
|
+
expect:
|
|
64
|
+
storeOffsetAs: beforeClose
|
|
65
|
+
- action: close
|
|
66
|
+
path: ${streamPath}
|
|
67
|
+
data: "final-message"
|
|
68
|
+
expect:
|
|
69
|
+
status: 200
|
|
70
|
+
- action: read
|
|
71
|
+
path: ${streamPath}
|
|
72
|
+
live: false
|
|
73
|
+
expect:
|
|
74
|
+
data: "initial-datafinal-message"
|
|
75
|
+
streamClosed: true
|
|
76
|
+
|
|
77
|
+
- id: close-returns-result
|
|
78
|
+
name: Close returns finalOffset
|
|
79
|
+
description: Closing a stream should return the final offset
|
|
80
|
+
setup:
|
|
81
|
+
- action: create
|
|
82
|
+
as: streamPath
|
|
83
|
+
contentType: text/plain
|
|
84
|
+
- action: append
|
|
85
|
+
path: ${streamPath}
|
|
86
|
+
data: "some-data"
|
|
87
|
+
operations:
|
|
88
|
+
- action: close
|
|
89
|
+
path: ${streamPath}
|
|
90
|
+
expect:
|
|
91
|
+
status: 200
|
|
92
|
+
|
|
93
|
+
- id: close-idempotent
|
|
94
|
+
name: Closing already-closed stream succeeds
|
|
95
|
+
description: Closing an already-closed stream should succeed (idempotent)
|
|
96
|
+
setup:
|
|
97
|
+
- action: create
|
|
98
|
+
as: streamPath
|
|
99
|
+
contentType: text/plain
|
|
100
|
+
- action: append
|
|
101
|
+
path: ${streamPath}
|
|
102
|
+
data: "data"
|
|
103
|
+
operations:
|
|
104
|
+
- action: close
|
|
105
|
+
path: ${streamPath}
|
|
106
|
+
expect:
|
|
107
|
+
status: 200
|
|
108
|
+
- action: close
|
|
109
|
+
path: ${streamPath}
|
|
110
|
+
expect:
|
|
111
|
+
status: 200
|
|
112
|
+
- action: head
|
|
113
|
+
path: ${streamPath}
|
|
114
|
+
expect:
|
|
115
|
+
streamClosed: true
|
|
116
|
+
|
|
117
|
+
- id: create-closed-stream
|
|
118
|
+
name: Create stream in closed state
|
|
119
|
+
description: Client should be able to create a stream that is immediately closed
|
|
120
|
+
operations:
|
|
121
|
+
- action: create
|
|
122
|
+
as: streamPath
|
|
123
|
+
contentType: text/plain
|
|
124
|
+
closed: true
|
|
125
|
+
expect:
|
|
126
|
+
status: 201
|
|
127
|
+
- action: head
|
|
128
|
+
path: ${streamPath}
|
|
129
|
+
expect:
|
|
130
|
+
streamClosed: true
|
|
131
|
+
- action: read
|
|
132
|
+
path: ${streamPath}
|
|
133
|
+
live: false
|
|
134
|
+
expect:
|
|
135
|
+
chunkCount: 0
|
|
136
|
+
upToDate: true
|
|
137
|
+
streamClosed: true
|
|
138
|
+
|
|
139
|
+
- id: create-closed-stream-with-body
|
|
140
|
+
name: Create closed stream with initial content (one request)
|
|
141
|
+
description: Client should be able to create a closed stream with body in a single request
|
|
142
|
+
operations:
|
|
143
|
+
# Create stream with data + closed in one request
|
|
144
|
+
- action: create
|
|
145
|
+
as: streamPath
|
|
146
|
+
contentType: text/plain
|
|
147
|
+
data: "initial-content"
|
|
148
|
+
closed: true
|
|
149
|
+
expect:
|
|
150
|
+
status: 201
|
|
151
|
+
# Verify the content is preserved and stream is closed
|
|
152
|
+
- action: read
|
|
153
|
+
path: ${streamPath}
|
|
154
|
+
live: false
|
|
155
|
+
expect:
|
|
156
|
+
data: "initial-content"
|
|
157
|
+
streamClosed: true
|
|
158
|
+
|
|
159
|
+
- id: create-then-close-with-body
|
|
160
|
+
name: Create stream then close with final content (two requests)
|
|
161
|
+
description: Client should be able to create a stream, then close it with final content
|
|
162
|
+
operations:
|
|
163
|
+
- action: create
|
|
164
|
+
as: streamPath
|
|
165
|
+
contentType: text/plain
|
|
166
|
+
expect:
|
|
167
|
+
status: 201
|
|
168
|
+
- action: close
|
|
169
|
+
path: ${streamPath}
|
|
170
|
+
data: "final-content"
|
|
171
|
+
expect:
|
|
172
|
+
status: 200
|
|
173
|
+
# Verify the content is preserved and stream is closed
|
|
174
|
+
- action: read
|
|
175
|
+
path: ${streamPath}
|
|
176
|
+
live: false
|
|
177
|
+
expect:
|
|
178
|
+
data: "final-content"
|
|
179
|
+
streamClosed: true
|
|
180
|
+
|
|
181
|
+
- id: append-to-closed-stream-fails
|
|
182
|
+
name: Append to closed stream fails with STREAM_CLOSED
|
|
183
|
+
description: Appending to a closed stream should return 409 with STREAM_CLOSED error
|
|
184
|
+
setup:
|
|
185
|
+
- action: create
|
|
186
|
+
as: streamPath
|
|
187
|
+
contentType: text/plain
|
|
188
|
+
- action: close
|
|
189
|
+
path: ${streamPath}
|
|
190
|
+
operations:
|
|
191
|
+
- action: append
|
|
192
|
+
path: ${streamPath}
|
|
193
|
+
data: "should-fail"
|
|
194
|
+
expect:
|
|
195
|
+
status: 409
|
|
196
|
+
errorCode: STREAM_CLOSED
|
|
197
|
+
|
|
198
|
+
# =============================================================================
|
|
199
|
+
# Reader Tests (Catch-up Mode)
|
|
200
|
+
# =============================================================================
|
|
201
|
+
|
|
202
|
+
- id: read-closed-stream-catchup
|
|
203
|
+
name: Reader sees streamClosed at final offset
|
|
204
|
+
description: When reading a closed stream to completion, streamClosed should be true
|
|
205
|
+
setup:
|
|
206
|
+
- action: create
|
|
207
|
+
as: streamPath
|
|
208
|
+
contentType: text/plain
|
|
209
|
+
- action: append
|
|
210
|
+
path: ${streamPath}
|
|
211
|
+
data: "chunk1"
|
|
212
|
+
- action: append
|
|
213
|
+
path: ${streamPath}
|
|
214
|
+
data: "chunk2"
|
|
215
|
+
- action: close
|
|
216
|
+
path: ${streamPath}
|
|
217
|
+
operations:
|
|
218
|
+
- action: read
|
|
219
|
+
path: ${streamPath}
|
|
220
|
+
live: false
|
|
221
|
+
expect:
|
|
222
|
+
data: "chunk1chunk2"
|
|
223
|
+
upToDate: true
|
|
224
|
+
streamClosed: true
|
|
225
|
+
|
|
226
|
+
- id: read-closed-stream-empty-eof
|
|
227
|
+
name: Closure discovered via empty body at tail
|
|
228
|
+
description: When already at tail of closed stream, read returns empty body with streamClosed
|
|
229
|
+
setup:
|
|
230
|
+
- action: create
|
|
231
|
+
as: streamPath
|
|
232
|
+
contentType: text/plain
|
|
233
|
+
- action: append
|
|
234
|
+
path: ${streamPath}
|
|
235
|
+
data: "data"
|
|
236
|
+
expect:
|
|
237
|
+
storeOffsetAs: tailOffset
|
|
238
|
+
operations:
|
|
239
|
+
# First read to get all data
|
|
240
|
+
- action: read
|
|
241
|
+
path: ${streamPath}
|
|
242
|
+
live: false
|
|
243
|
+
expect:
|
|
244
|
+
storeOffsetAs: afterDataOffset
|
|
245
|
+
upToDate: true
|
|
246
|
+
streamClosed: false
|
|
247
|
+
# Close the stream
|
|
248
|
+
- action: close
|
|
249
|
+
path: ${streamPath}
|
|
250
|
+
# Read again from tail - should get empty body with streamClosed
|
|
251
|
+
- action: read
|
|
252
|
+
path: ${streamPath}
|
|
253
|
+
offset: ${afterDataOffset}
|
|
254
|
+
live: false
|
|
255
|
+
expect:
|
|
256
|
+
chunkCount: 0
|
|
257
|
+
upToDate: true
|
|
258
|
+
streamClosed: true
|
|
259
|
+
|
|
260
|
+
- id: head-closed-stream
|
|
261
|
+
name: HEAD returns streamClosed
|
|
262
|
+
description: HEAD request should indicate when a stream is closed
|
|
263
|
+
setup:
|
|
264
|
+
- action: create
|
|
265
|
+
as: streamPath
|
|
266
|
+
contentType: text/plain
|
|
267
|
+
- action: append
|
|
268
|
+
path: ${streamPath}
|
|
269
|
+
data: "content"
|
|
270
|
+
operations:
|
|
271
|
+
# HEAD before close
|
|
272
|
+
- action: head
|
|
273
|
+
path: ${streamPath}
|
|
274
|
+
expect:
|
|
275
|
+
status: 200
|
|
276
|
+
streamClosed: false
|
|
277
|
+
# Close the stream
|
|
278
|
+
- action: close
|
|
279
|
+
path: ${streamPath}
|
|
280
|
+
# HEAD after close
|
|
281
|
+
- action: head
|
|
282
|
+
path: ${streamPath}
|
|
283
|
+
expect:
|
|
284
|
+
status: 200
|
|
285
|
+
streamClosed: true
|
|
286
|
+
|
|
287
|
+
# =============================================================================
|
|
288
|
+
# Reader Tests (Live Modes)
|
|
289
|
+
# =============================================================================
|
|
290
|
+
|
|
291
|
+
- id: long-poll-closed-stream-immediate
|
|
292
|
+
name: Long-poll returns immediately on closed stream
|
|
293
|
+
description: Long-poll at tail of closed stream should return immediately without waiting
|
|
294
|
+
requires:
|
|
295
|
+
- long-poll
|
|
296
|
+
setup:
|
|
297
|
+
- action: create
|
|
298
|
+
as: streamPath
|
|
299
|
+
contentType: text/plain
|
|
300
|
+
- action: append
|
|
301
|
+
path: ${streamPath}
|
|
302
|
+
data: "data"
|
|
303
|
+
expect:
|
|
304
|
+
storeOffsetAs: dataOffset
|
|
305
|
+
- action: close
|
|
306
|
+
path: ${streamPath}
|
|
307
|
+
operations:
|
|
308
|
+
# First read to get all data and reach the tail
|
|
309
|
+
- action: read
|
|
310
|
+
path: ${streamPath}
|
|
311
|
+
live: false
|
|
312
|
+
expect:
|
|
313
|
+
storeOffsetAs: tailOffset
|
|
314
|
+
upToDate: true
|
|
315
|
+
streamClosed: true
|
|
316
|
+
# Long-poll from tail should return immediately with streamClosed
|
|
317
|
+
- action: read
|
|
318
|
+
path: ${streamPath}
|
|
319
|
+
offset: ${tailOffset}
|
|
320
|
+
live: long-poll
|
|
321
|
+
timeoutMs: 5000
|
|
322
|
+
expect:
|
|
323
|
+
chunkCount: 0
|
|
324
|
+
upToDate: true
|
|
325
|
+
streamClosed: true
|
|
326
|
+
|
|
327
|
+
- id: sse-closed-stream-final-event
|
|
328
|
+
name: SSE final event has streamClosed
|
|
329
|
+
description: SSE connection to closed stream should receive streamClosed in final control event
|
|
330
|
+
requires:
|
|
331
|
+
- sse
|
|
332
|
+
setup:
|
|
333
|
+
- action: create
|
|
334
|
+
as: streamPath
|
|
335
|
+
contentType: text/plain
|
|
336
|
+
- action: append
|
|
337
|
+
path: ${streamPath}
|
|
338
|
+
data: "sse-data"
|
|
339
|
+
- action: close
|
|
340
|
+
path: ${streamPath}
|
|
341
|
+
operations:
|
|
342
|
+
# Read via SSE - may need multiple chunks as SSE delivers data then control event
|
|
343
|
+
- action: read
|
|
344
|
+
path: ${streamPath}
|
|
345
|
+
live: sse
|
|
346
|
+
timeoutMs: 5000
|
|
347
|
+
expect:
|
|
348
|
+
data: "sse-data"
|
|
349
|
+
streamClosed: true
|
|
350
|
+
|
|
351
|
+
# =============================================================================
|
|
352
|
+
# State Matrix Tests
|
|
353
|
+
# =============================================================================
|
|
354
|
+
|
|
355
|
+
- id: state-caught-up-open
|
|
356
|
+
name: State - caught up on open stream
|
|
357
|
+
description: At tail of open stream, upToDate should be true and streamClosed should be false
|
|
358
|
+
setup:
|
|
359
|
+
- action: create
|
|
360
|
+
as: streamPath
|
|
361
|
+
contentType: text/plain
|
|
362
|
+
- action: append
|
|
363
|
+
path: ${streamPath}
|
|
364
|
+
data: "content"
|
|
365
|
+
operations:
|
|
366
|
+
- action: read
|
|
367
|
+
path: ${streamPath}
|
|
368
|
+
live: false
|
|
369
|
+
expect:
|
|
370
|
+
data: "content"
|
|
371
|
+
upToDate: true
|
|
372
|
+
streamClosed: false
|
|
373
|
+
|
|
374
|
+
- id: state-complete
|
|
375
|
+
name: State - complete (closed stream at tail)
|
|
376
|
+
description: At tail of closed stream, both upToDate and streamClosed should be true
|
|
377
|
+
setup:
|
|
378
|
+
- action: create
|
|
379
|
+
as: streamPath
|
|
380
|
+
contentType: text/plain
|
|
381
|
+
- action: append
|
|
382
|
+
path: ${streamPath}
|
|
383
|
+
data: "final-content"
|
|
384
|
+
- action: close
|
|
385
|
+
path: ${streamPath}
|
|
386
|
+
operations:
|
|
387
|
+
- action: read
|
|
388
|
+
path: ${streamPath}
|
|
389
|
+
live: false
|
|
390
|
+
expect:
|
|
391
|
+
data: "final-content"
|
|
392
|
+
upToDate: true
|
|
393
|
+
streamClosed: true
|
|
394
|
+
|
|
395
|
+
# Note: This test requires server-side chunk pagination to properly test
|
|
396
|
+
# partial reads. Currently skipped because maxChunks only limits client-side
|
|
397
|
+
# processing, not what the server returns. The server sends Stream-Closed: true
|
|
398
|
+
# on the full response when a stream is closed.
|
|
399
|
+
# - id: state-catching-up-closed-stream
|
|
400
|
+
# name: State - catching up to closed stream
|
|
401
|
+
# description: Partial read of closed stream shows streamClosed only at final offset
|
|
402
|
+
# ...
|
|
403
|
+
|
|
404
|
+
- id: state-open-stream-partial-read
|
|
405
|
+
name: State - partial read of open stream
|
|
406
|
+
description: Partial read of open stream shows streamClosed=false and upToDate=false
|
|
407
|
+
requires:
|
|
408
|
+
- long-poll
|
|
409
|
+
setup:
|
|
410
|
+
- action: create
|
|
411
|
+
as: streamPath
|
|
412
|
+
contentType: text/plain
|
|
413
|
+
- action: append
|
|
414
|
+
path: ${streamPath}
|
|
415
|
+
data: "part1"
|
|
416
|
+
- action: append
|
|
417
|
+
path: ${streamPath}
|
|
418
|
+
data: "part2"
|
|
419
|
+
operations:
|
|
420
|
+
# Partial read - only get first chunk
|
|
421
|
+
- action: read
|
|
422
|
+
path: ${streamPath}
|
|
423
|
+
maxChunks: 1
|
|
424
|
+
live: long-poll
|
|
425
|
+
timeoutMs: 5000
|
|
426
|
+
expect:
|
|
427
|
+
streamClosed: false
|
|
428
|
+
storeOffsetAs: partialOffset
|
|
429
|
+
# Read from partial offset to end
|
|
430
|
+
- action: read
|
|
431
|
+
path: ${streamPath}
|
|
432
|
+
offset: ${partialOffset}
|
|
433
|
+
live: false
|
|
434
|
+
expect:
|
|
435
|
+
upToDate: true
|
|
436
|
+
streamClosed: false
|
|
437
|
+
|
|
438
|
+
# =============================================================================
|
|
439
|
+
# IdempotentProducer Tests
|
|
440
|
+
# =============================================================================
|
|
441
|
+
|
|
442
|
+
- id: idempotent-producer-close
|
|
443
|
+
name: IdempotentProducer.close() closes stream
|
|
444
|
+
description: IdempotentProducer.close() should close the stream using producer headers
|
|
445
|
+
setup:
|
|
446
|
+
- action: create
|
|
447
|
+
as: streamPath
|
|
448
|
+
contentType: text/plain
|
|
449
|
+
operations:
|
|
450
|
+
- action: idempotent-append
|
|
451
|
+
path: ${streamPath}
|
|
452
|
+
producerId: producer-1
|
|
453
|
+
data: "message-1"
|
|
454
|
+
- action: idempotent-close
|
|
455
|
+
path: ${streamPath}
|
|
456
|
+
producerId: producer-1
|
|
457
|
+
expect:
|
|
458
|
+
status: 200
|
|
459
|
+
- action: head
|
|
460
|
+
path: ${streamPath}
|
|
461
|
+
expect:
|
|
462
|
+
streamClosed: true
|
|
463
|
+
- action: read
|
|
464
|
+
path: ${streamPath}
|
|
465
|
+
live: false
|
|
466
|
+
expect:
|
|
467
|
+
data: "message-1"
|
|
468
|
+
streamClosed: true
|
|
469
|
+
|
|
470
|
+
- id: idempotent-producer-close-with-final-message
|
|
471
|
+
name: IdempotentProducer.close() with final message
|
|
472
|
+
description: IdempotentProducer.close(finalMessage) should append and close atomically
|
|
473
|
+
setup:
|
|
474
|
+
- action: create
|
|
475
|
+
as: streamPath
|
|
476
|
+
contentType: text/plain
|
|
477
|
+
operations:
|
|
478
|
+
- action: idempotent-append
|
|
479
|
+
path: ${streamPath}
|
|
480
|
+
producerId: producer-1
|
|
481
|
+
data: "message-1"
|
|
482
|
+
- action: idempotent-close
|
|
483
|
+
path: ${streamPath}
|
|
484
|
+
producerId: producer-1
|
|
485
|
+
data: "final-message"
|
|
486
|
+
expect:
|
|
487
|
+
status: 200
|
|
488
|
+
- action: read
|
|
489
|
+
path: ${streamPath}
|
|
490
|
+
live: false
|
|
491
|
+
expect:
|
|
492
|
+
data: "message-1final-message"
|
|
493
|
+
streamClosed: true
|
|
494
|
+
|
|
495
|
+
- id: idempotent-producer-detach-does-not-close
|
|
496
|
+
name: IdempotentProducer.detach() does not close stream
|
|
497
|
+
description: IdempotentProducer.detach() should stop producer without closing stream
|
|
498
|
+
setup:
|
|
499
|
+
- action: create
|
|
500
|
+
as: streamPath
|
|
501
|
+
contentType: text/plain
|
|
502
|
+
operations:
|
|
503
|
+
- action: idempotent-append
|
|
504
|
+
path: ${streamPath}
|
|
505
|
+
producerId: producer-1
|
|
506
|
+
data: "message-1"
|
|
507
|
+
- action: idempotent-detach
|
|
508
|
+
path: ${streamPath}
|
|
509
|
+
producerId: producer-1
|
|
510
|
+
expect:
|
|
511
|
+
status: 200
|
|
512
|
+
- action: head
|
|
513
|
+
path: ${streamPath}
|
|
514
|
+
expect:
|
|
515
|
+
streamClosed: false
|
|
516
|
+
# Stream should still be open for further appends
|
|
517
|
+
- action: append
|
|
518
|
+
path: ${streamPath}
|
|
519
|
+
data: "message-2"
|
|
520
|
+
expect:
|
|
521
|
+
status: 200
|
|
522
|
+
- action: read
|
|
523
|
+
path: ${streamPath}
|
|
524
|
+
live: false
|
|
525
|
+
expect:
|
|
526
|
+
data: "message-1message-2"
|
|
527
|
+
streamClosed: false
|
|
528
|
+
|
|
529
|
+
- id: idempotent-producer-close-idempotent
|
|
530
|
+
name: IdempotentProducer.close() is idempotent
|
|
531
|
+
description: Calling close() multiple times should succeed (idempotent)
|
|
532
|
+
setup:
|
|
533
|
+
- action: create
|
|
534
|
+
as: streamPath
|
|
535
|
+
contentType: text/plain
|
|
536
|
+
- action: idempotent-append
|
|
537
|
+
path: ${streamPath}
|
|
538
|
+
producerId: producer-1
|
|
539
|
+
data: "content"
|
|
540
|
+
operations:
|
|
541
|
+
- action: idempotent-close
|
|
542
|
+
path: ${streamPath}
|
|
543
|
+
producerId: producer-1
|
|
544
|
+
expect:
|
|
545
|
+
status: 200
|
|
546
|
+
# Calling close again should succeed (idempotent)
|
|
547
|
+
- action: idempotent-close
|
|
548
|
+
path: ${streamPath}
|
|
549
|
+
producerId: producer-1
|
|
550
|
+
expect:
|
|
551
|
+
status: 200
|
|
552
|
+
- action: head
|
|
553
|
+
path: ${streamPath}
|
|
554
|
+
expect:
|
|
555
|
+
streamClosed: true
|
|
556
|
+
|
|
557
|
+
- id: idempotent-producer-close-conflict
|
|
558
|
+
name: Different producer closing already-closed stream fails
|
|
559
|
+
description: When a different producer tries to close an already-closed stream, it should fail with STREAM_CLOSED
|
|
560
|
+
setup:
|
|
561
|
+
- action: create
|
|
562
|
+
as: streamPath
|
|
563
|
+
contentType: text/plain
|
|
564
|
+
- action: idempotent-append
|
|
565
|
+
path: ${streamPath}
|
|
566
|
+
producerId: producer-1
|
|
567
|
+
data: "content"
|
|
568
|
+
- action: idempotent-close
|
|
569
|
+
path: ${streamPath}
|
|
570
|
+
producerId: producer-1
|
|
571
|
+
operations:
|
|
572
|
+
# Different producer tries to close - should fail
|
|
573
|
+
- action: idempotent-close
|
|
574
|
+
path: ${streamPath}
|
|
575
|
+
producerId: producer-2
|
|
576
|
+
expect:
|
|
577
|
+
status: 409
|
|
578
|
+
errorCode: STREAM_CLOSED
|
|
579
|
+
# Stream should still be closed (by producer-1)
|
|
580
|
+
- action: head
|
|
581
|
+
path: ${streamPath}
|
|
582
|
+
expect:
|
|
583
|
+
streamClosed: true
|
|
584
|
+
|
|
585
|
+
# =============================================================================
|
|
586
|
+
# Additional Edge Case Tests (from PR review)
|
|
587
|
+
# =============================================================================
|
|
588
|
+
|
|
589
|
+
- id: atomic-close-dedup-with-producer
|
|
590
|
+
name: Retry of append-and-close with same producer tuple is deduplicated
|
|
591
|
+
description: Retrying a close-with-body using same (pid, epoch, seq) returns 204 and stream has exactly one copy of data
|
|
592
|
+
setup:
|
|
593
|
+
- action: create
|
|
594
|
+
as: streamPath
|
|
595
|
+
contentType: text/plain
|
|
596
|
+
operations:
|
|
597
|
+
# First close with final message
|
|
598
|
+
- action: idempotent-close
|
|
599
|
+
path: ${streamPath}
|
|
600
|
+
producerId: producer-1
|
|
601
|
+
data: "final-data"
|
|
602
|
+
expect:
|
|
603
|
+
status: 200
|
|
604
|
+
# Retry with same producer tuple - should be deduplicated
|
|
605
|
+
- action: idempotent-close
|
|
606
|
+
path: ${streamPath}
|
|
607
|
+
producerId: producer-1
|
|
608
|
+
data: "final-data"
|
|
609
|
+
expect:
|
|
610
|
+
status: 200
|
|
611
|
+
# Verify only one copy of data exists
|
|
612
|
+
- action: read
|
|
613
|
+
path: ${streamPath}
|
|
614
|
+
live: false
|
|
615
|
+
expect:
|
|
616
|
+
data: "final-data"
|
|
617
|
+
streamClosed: true
|
|
618
|
+
|
|
619
|
+
- id: close-nonexistent-stream
|
|
620
|
+
name: Close nonexistent stream returns 404
|
|
621
|
+
description: POST with Stream-Closed to nonexistent URL returns 404
|
|
622
|
+
operations:
|
|
623
|
+
- action: close
|
|
624
|
+
path: /nonexistent-close-stream-12345
|
|
625
|
+
expect:
|
|
626
|
+
status: 404
|
|
627
|
+
|
|
628
|
+
- id: read-offset-beyond-closed-tail
|
|
629
|
+
name: Read offset past closed stream tail returns empty with streamClosed
|
|
630
|
+
description: Reading from an offset past the final data returns empty body + streamClosed + upToDate
|
|
631
|
+
setup:
|
|
632
|
+
- action: create
|
|
633
|
+
as: streamPath
|
|
634
|
+
contentType: text/plain
|
|
635
|
+
- action: append
|
|
636
|
+
path: ${streamPath}
|
|
637
|
+
data: "data"
|
|
638
|
+
- action: close
|
|
639
|
+
path: ${streamPath}
|
|
640
|
+
operations:
|
|
641
|
+
# First read to get all data
|
|
642
|
+
- action: read
|
|
643
|
+
path: ${streamPath}
|
|
644
|
+
live: false
|
|
645
|
+
expect:
|
|
646
|
+
storeOffsetAs: tailOffset
|
|
647
|
+
upToDate: true
|
|
648
|
+
streamClosed: true
|
|
649
|
+
# Read from tail (which is past the data) - should get empty + closed
|
|
650
|
+
- action: read
|
|
651
|
+
path: ${streamPath}
|
|
652
|
+
offset: ${tailOffset}
|
|
653
|
+
live: false
|
|
654
|
+
expect:
|
|
655
|
+
chunkCount: 0
|
|
656
|
+
upToDate: true
|
|
657
|
+
streamClosed: true
|
|
658
|
+
|
|
659
|
+
- id: head-closed-stream-returns-tail-offset
|
|
660
|
+
name: HEAD on closed stream returns correct tail offset
|
|
661
|
+
description: HEAD after close-with-append returns offset after the final data
|
|
662
|
+
setup:
|
|
663
|
+
- action: create
|
|
664
|
+
as: streamPath
|
|
665
|
+
contentType: text/plain
|
|
666
|
+
operations:
|
|
667
|
+
# Get initial offset
|
|
668
|
+
- action: head
|
|
669
|
+
path: ${streamPath}
|
|
670
|
+
expect:
|
|
671
|
+
storeOffsetAs: initialOffset
|
|
672
|
+
# Close with final message
|
|
673
|
+
- action: close
|
|
674
|
+
path: ${streamPath}
|
|
675
|
+
data: "final-content"
|
|
676
|
+
expect:
|
|
677
|
+
status: 200
|
|
678
|
+
# HEAD should return new offset (not initial)
|
|
679
|
+
- action: head
|
|
680
|
+
path: ${streamPath}
|
|
681
|
+
expect:
|
|
682
|
+
streamClosed: true
|
|
683
|
+
hasOffset: true
|
|
684
|
+
|
|
685
|
+
- id: delete-closed-stream
|
|
686
|
+
name: Deletion overrides closure
|
|
687
|
+
description: Deleting a closed stream returns 404 after, not Stream-Closed
|
|
688
|
+
setup:
|
|
689
|
+
- action: create
|
|
690
|
+
as: streamPath
|
|
691
|
+
contentType: text/plain
|
|
692
|
+
- action: append
|
|
693
|
+
path: ${streamPath}
|
|
694
|
+
data: "data"
|
|
695
|
+
- action: close
|
|
696
|
+
path: ${streamPath}
|
|
697
|
+
operations:
|
|
698
|
+
# Verify closed
|
|
699
|
+
- action: head
|
|
700
|
+
path: ${streamPath}
|
|
701
|
+
expect:
|
|
702
|
+
streamClosed: true
|
|
703
|
+
# Delete
|
|
704
|
+
- action: delete
|
|
705
|
+
path: ${streamPath}
|
|
706
|
+
expect:
|
|
707
|
+
status: 200
|
|
708
|
+
# Should be 404 now (not 409/STREAM_CLOSED)
|
|
709
|
+
- action: head
|
|
710
|
+
path: ${streamPath}
|
|
711
|
+
expect:
|
|
712
|
+
status: 404
|
|
713
|
+
|
|
714
|
+
- id: create-closed-with-ttl
|
|
715
|
+
name: Create closed stream with TTL
|
|
716
|
+
description: Closed stream + TTL both apply, stream expires normally
|
|
717
|
+
operations:
|
|
718
|
+
- action: create
|
|
719
|
+
as: streamPath
|
|
720
|
+
contentType: text/plain
|
|
721
|
+
ttlSeconds: 60
|
|
722
|
+
closed: true
|
|
723
|
+
expect:
|
|
724
|
+
status: 201
|
|
725
|
+
# Verify both closed and has TTL
|
|
726
|
+
- action: head
|
|
727
|
+
path: ${streamPath}
|
|
728
|
+
expect:
|
|
729
|
+
streamClosed: true
|
|
730
|
+
|
|
731
|
+
- id: close-with-body-retry-different-body
|
|
732
|
+
name: Producer sends close+body A, retries same seq with body B - deduplicates to A
|
|
733
|
+
description: When retrying a close with different body content, the original body is kept
|
|
734
|
+
setup:
|
|
735
|
+
- action: create
|
|
736
|
+
as: streamPath
|
|
737
|
+
contentType: text/plain
|
|
738
|
+
operations:
|
|
739
|
+
# Close with body A
|
|
740
|
+
- action: idempotent-close
|
|
741
|
+
path: ${streamPath}
|
|
742
|
+
producerId: producer-1
|
|
743
|
+
data: "body-A"
|
|
744
|
+
expect:
|
|
745
|
+
status: 200
|
|
746
|
+
# Retry with same seq but different body - should be deduplicated to original
|
|
747
|
+
- action: idempotent-close
|
|
748
|
+
path: ${streamPath}
|
|
749
|
+
producerId: producer-1
|
|
750
|
+
data: "body-B"
|
|
751
|
+
expect:
|
|
752
|
+
status: 200
|
|
753
|
+
# Verify original body is preserved
|
|
754
|
+
- action: read
|
|
755
|
+
path: ${streamPath}
|
|
756
|
+
live: false
|
|
757
|
+
expect:
|
|
758
|
+
data: "body-A"
|
|
759
|
+
streamClosed: true
|