diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 3a372f0f..86d5ead2 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/elfoundation/hatch/internal/store" "github.com/go-chi/chi/v5" @@ -129,6 +130,80 @@ func TestCaptureRecordsAllVerbs(t *testing.T) { } } +func TestSSEStreamReceivesEventOnCapture(t *testing.T) { + repo := newFakeRepo() + srv := httptest.NewServer(testRouter(repo)) + defer srv.Close() + + // Use separate clients with separate transports to avoid + // connection-pool contention with the long-lived SSE connection. + sseClient := &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, + } + captureClient := &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, + } + + // Subscribe to SSE with a cancellable context. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sseReq, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/ep/events", nil) + if err != nil { + t.Fatalf("SSE request create failed: %v", err) + } + sseResp, err := sseClient.Do(sseReq) + if err != nil { + t.Fatalf("SSE GET failed: %v", err) + } + defer sseResp.Body.Close() + + if sseResp.StatusCode != http.StatusOK { + t.Fatalf("SSE returned %d", sseResp.StatusCode) + } + ct := sseResp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("SSE Content-Type is %q, expected text/event-stream", ct) + } + + // Read SSE body in a goroutine; capture in main goroutine. + bodyCh := make(chan string, 1) + go func() { + buf := make([]byte, 4096) + n, _ := sseResp.Body.Read(buf) + bodyCh <- string(buf[:n]) + }() + + // Small sleep to let the SSE subscription register. + time.Sleep(10 * time.Millisecond) + + // Capture a request on the same server. + captureResp, err := captureClient.Post(srv.URL+"/ep", "application/json", strings.NewReader(`{"msg":"hello"}`)) + if err != nil { + t.Fatalf("capture POST failed: %v", err) + } + captureResp.Body.Close() + if captureResp.StatusCode != http.StatusOK { + t.Fatalf("capture returned %d", captureResp.StatusCode) + } + + // Wait for the SSE event with a timeout. + select { + case body := <-bodyCh: + if !strings.Contains(body, "data:") { + t.Fatalf("SSE stream missing 'data:' prefix: %q", body) + } + if !strings.Contains(body, "\"method\":\"POST\"") { + t.Fatalf("SSE stream missing POST method: %q", body) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for SSE event") + } + + // Cancel context to close the SSE connection. + cancel() +} + func TestCaptureReturnsJSON200(t *testing.T) { r := testRouter(newFakeRepo()) w := httptest.NewRecorder() diff --git a/internal/handler/sse.go b/internal/handler/sse.go index 7d210fdb..46c1c5f5 100644 --- a/internal/handler/sse.go +++ b/internal/handler/sse.go @@ -80,6 +80,7 @@ func HandleSSE(repo store.Repository) http.HandlerFunc { w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) + flusher.Flush() ch := hub.subscribe(endpointID) defer hub.unsubscribe(endpointID, ch)