@@ -72,17 +72,10 @@ defmodule Lightning.ApolloClient.SSEStreamTest do
7272 500
7373 end
7474
75- test "times out hanging streams" , % { session_id: session_id } do
76- # Timeout is based on Apollo config, which for tests should be short
77- # This test verifies that the stream times out if no data arrives
78-
79- # Override the default stub with a short timeout for this test
80- stub ( Lightning.MockConfig , :apollo , fn
81- # Very short timeout for testing
82- :timeout -> 100
83- :endpoint -> "http://localhost:3000"
84- :ai_assistant_api_key -> "test_key"
85- end )
75+ test "times out hanging streams and broadcasts error" , % {
76+ session_id: session_id
77+ } do
78+ # Test that timeout handling works correctly
8679
8780 url = "http://localhost:3000/services/job_chat/stream"
8881
@@ -91,18 +84,68 @@ defmodule Lightning.ApolloClient.SSEStreamTest do
9184 "stream" => true
9285 }
9386
94- { :ok , _pid } = SSEStream . start_stream ( url , payload )
87+ { :ok , pid } = SSEStream . start_stream ( url , payload )
88+
89+ # Send timeout message directly to test the handler
90+ send ( pid , :stream_timeout )
91+
92+ # Should receive timeout error broadcast
93+ assert_receive { :ai_assistant , :streaming_error ,
94+ % {
95+ session_id: ^ session_id ,
96+ error: "Request timed out. Please try again."
97+ } } ,
98+ 500
99+ end
100+
101+ test "ignores timeout after stream completes" , % { session_id: session_id } do
102+ # Test that timeout is ignored if stream already completed
103+
104+ url = "http://localhost:3000/services/job_chat/stream"
105+
106+ payload = % {
107+ "lightning_session_id" => session_id ,
108+ "stream" => true
109+ }
110+
111+ { :ok , pid } = SSEStream . start_stream ( url , payload )
112+
113+ # First complete the stream
114+ send ( pid , { :sse_complete } )
115+
116+ # Process should stop normally
117+ ref = Process . monitor ( pid )
118+ assert_receive { :DOWN , ^ ref , :process , ^ pid , :normal } , 500
119+
120+ # If we somehow send timeout after completion, it should be ignored
121+ # (process is already dead so we can't test this directly)
122+ end
123+
124+ test "handles completion message and cancels timeout" , % {
125+ session_id: session_id
126+ } do
127+ # Test that :sse_complete properly cancels the timeout timer
128+
129+ url = "http://localhost:3000/services/job_chat/stream"
95130
96- # Wait for timeout (100ms + 10s buffer = 10.1s, but for test we use smaller values)
97- # Since timeout is 100ms, the actual timeout will be 100 + 10000 = 10100ms
98- # But we can verify the GenServer eventually stops
99- Process . sleep ( 150 )
131+ payload = % {
132+ "lightning_session_id" => session_id ,
133+ "stream" => true
134+ }
135+
136+ { :ok , pid } = SSEStream . start_stream ( url , payload )
100137
101- # The GenServer should still be trying (hasn't hit the actual timeout yet)
102- # For a proper test, we'd need to mock the time or use shorter timeouts
138+ # Send completion message
139+ send ( pid , { :sse_complete } )
140+
141+ # Process should stop normally (not from timeout)
142+ ref = Process . monitor ( pid )
143+ assert_receive { :DOWN , ^ ref , :process , ^ pid , :normal } , 500
103144 end
104145
105- test "handles connection failures" , % { session_id: session_id } do
146+ test "handles connection failures with econnrefused" , % {
147+ session_id: session_id
148+ } do
106149 # When Finch cannot connect, the stream should broadcast an error
107150
108151 url = "http://localhost:3000/services/job_chat/stream"
@@ -128,6 +171,66 @@ defmodule Lightning.ApolloClient.SSEStreamTest do
128171 assert error =~ "Connection error"
129172 end
130173
174+ test "handles timeout error from Finch" , % { session_id: session_id } do
175+ url = "http://localhost:3000/services/job_chat/stream"
176+
177+ payload = % {
178+ "lightning_session_id" => session_id ,
179+ "stream" => true
180+ }
181+
182+ { :ok , pid } = SSEStream . start_stream ( url , payload )
183+
184+ send ( pid , { :sse_error , :timeout } )
185+
186+ assert_receive { :ai_assistant , :streaming_error ,
187+ % {
188+ session_id: ^ session_id ,
189+ error: "Connection timed out"
190+ } } ,
191+ 500
192+ end
193+
194+ test "handles closed connection error" , % { session_id: session_id } do
195+ url = "http://localhost:3000/services/job_chat/stream"
196+
197+ payload = % {
198+ "lightning_session_id" => session_id ,
199+ "stream" => true
200+ }
201+
202+ { :ok , pid } = SSEStream . start_stream ( url , payload )
203+
204+ send ( pid , { :sse_error , :closed } )
205+
206+ assert_receive { :ai_assistant , :streaming_error ,
207+ % {
208+ session_id: ^ session_id ,
209+ error: "Connection closed unexpectedly"
210+ } } ,
211+ 500
212+ end
213+
214+ test "handles shutdown error" , % { session_id: session_id } do
215+ url = "http://localhost:3000/services/job_chat/stream"
216+
217+ payload = % {
218+ "lightning_session_id" => session_id ,
219+ "stream" => true
220+ }
221+
222+ { :ok , pid } = SSEStream . start_stream ( url , payload )
223+
224+ send ( pid , { :sse_error , { :shutdown , :some_reason } } )
225+
226+ assert_receive { :ai_assistant , :streaming_error ,
227+ % {
228+ session_id: ^ session_id ,
229+ error: "Server shut down"
230+ } } ,
231+ 500
232+ end
233+
131234 test "handles HTTP error responses" , % { session_id: session_id } do
132235 # Test that HTTP error status codes result in error broadcasts
133236
@@ -264,5 +367,163 @@ defmodule Lightning.ApolloClient.SSEStreamTest do
264367 assert payload_data . meta [ "model" ] == "claude-3"
265368 assert payload_data . code == "workflow: test"
266369 end
370+
371+ test "handles complete event with invalid JSON" , % { session_id: session_id } do
372+ # Test that malformed complete payloads are handled gracefully
373+
374+ url = "http://localhost:3000/services/job_chat/stream"
375+
376+ payload = % {
377+ "lightning_session_id" => session_id ,
378+ "stream" => true
379+ }
380+
381+ { :ok , pid } = SSEStream . start_stream ( url , payload )
382+
383+ # Monitor the process to ensure it doesn't crash
384+ ref = Process . monitor ( pid )
385+
386+ # Send complete event with invalid JSON
387+ send ( pid , { :sse_event , "complete" , "not valid json {" } )
388+
389+ # Should not crash - verify process is still alive after a reasonable time
390+ refute_receive { :DOWN , ^ ref , :process , ^ pid , _reason } , 200
391+ assert Process . alive? ( pid )
392+ end
393+
394+ test "handles log events" , % { session_id: session_id } do
395+ # Test that log events are handled (just logged, no broadcast)
396+
397+ url = "http://localhost:3000/services/job_chat/stream"
398+
399+ payload = % {
400+ "lightning_session_id" => session_id ,
401+ "stream" => true
402+ }
403+
404+ { :ok , pid } = SSEStream . start_stream ( url , payload )
405+
406+ # Monitor the process
407+ ref = Process . monitor ( pid )
408+
409+ # Send log event
410+ send ( pid , { :sse_event , "log" , "Some log message" } )
411+
412+ # Should not crash
413+ refute_receive { :DOWN , ^ ref , :process , ^ pid , _reason } , 200
414+ assert Process . alive? ( pid )
415+ end
416+
417+ test "handles unknown event types" , % { session_id: session_id } do
418+ # Test that unknown event types are handled gracefully
419+
420+ url = "http://localhost:3000/services/job_chat/stream"
421+
422+ payload = % {
423+ "lightning_session_id" => session_id ,
424+ "stream" => true
425+ }
426+
427+ { :ok , pid } = SSEStream . start_stream ( url , payload )
428+
429+ # Monitor the process
430+ ref = Process . monitor ( pid )
431+
432+ # Send unknown event type
433+ send ( pid , { :sse_event , "some_unknown_event" , "data" } )
434+
435+ # Should not crash
436+ refute_receive { :DOWN , ^ ref , :process , ^ pid , _reason } , 200
437+ assert Process . alive? ( pid )
438+ end
439+
440+ test "handles content_block_delta with invalid JSON" , % {
441+ session_id: session_id
442+ } do
443+ # Test that malformed delta events don't crash
444+
445+ url = "http://localhost:3000/services/job_chat/stream"
446+
447+ payload = % {
448+ "lightning_session_id" => session_id ,
449+ "stream" => true
450+ }
451+
452+ { :ok , pid } = SSEStream . start_stream ( url , payload )
453+
454+ # Monitor the process
455+ ref = Process . monitor ( pid )
456+
457+ # Send invalid delta data
458+ send ( pid , { :sse_event , "content_block_delta" , "invalid json" } )
459+
460+ # Should not crash
461+ refute_receive { :DOWN , ^ ref , :process , ^ pid , _reason } , 200
462+ assert Process . alive? ( pid )
463+ end
464+
465+ test "handles error event with message field" , % { session_id: session_id } do
466+ url = "http://localhost:3000/services/job_chat/stream"
467+
468+ payload = % {
469+ "lightning_session_id" => session_id ,
470+ "stream" => true
471+ }
472+
473+ { :ok , pid } = SSEStream . start_stream ( url , payload )
474+
475+ error_data = Jason . encode! ( % { "message" => "Custom error message" } )
476+ send ( pid , { :sse_event , "error" , error_data } )
477+
478+ assert_receive { :ai_assistant , :streaming_error ,
479+ % {
480+ session_id: ^ session_id ,
481+ error: "Custom error message"
482+ } } ,
483+ 500
484+ end
485+
486+ test "handles error event with error field" , % { session_id: session_id } do
487+ url = "http://localhost:3000/services/job_chat/stream"
488+
489+ payload = % {
490+ "lightning_session_id" => session_id ,
491+ "stream" => true
492+ }
493+
494+ { :ok , pid } = SSEStream . start_stream ( url , payload )
495+
496+ error_data = Jason . encode! ( % { "error" => "Another error format" } )
497+ send ( pid , { :sse_event , "error" , error_data } )
498+
499+ assert_receive { :ai_assistant , :streaming_error ,
500+ % {
501+ session_id: ^ session_id ,
502+ error: "Another error format"
503+ } } ,
504+ 500
505+ end
506+
507+ test "handles error event with invalid JSON" , % { session_id: session_id } do
508+ url = "http://localhost:3000/services/job_chat/stream"
509+
510+ payload = % {
511+ "lightning_session_id" => session_id ,
512+ "stream" => true
513+ }
514+
515+ { :ok , pid } = SSEStream . start_stream ( url , payload )
516+
517+ # Send malformed error data
518+ send ( pid , { :sse_event , "error" , "not json" } )
519+
520+ # Should use fallback error message
521+ assert_receive { :ai_assistant , :streaming_error ,
522+ % {
523+ session_id: ^ session_id ,
524+ error: "An error occurred while streaming"
525+ } } ,
526+ 500
527+ end
267528 end
268529end
0 commit comments