@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.
@@ -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