touka/sse_test.go
wjqserver 559aefeb85 fix(SSE): capture Writer before goroutine, use select for channel send
Address PR review feedback:
- Capture w := c.Writer before goroutine start, use w (not c.Writer)
  inside the goroutine to avoid holding *Context reference
- Move channel send into select alongside context cancellation in all
  examples and tests, preventing goroutine leak when client disconnects
  while blocked on unbuffered send
2026-03-29 16:50:37 +08:00

142 lines
3.6 KiB
Go

package touka
import (
"context"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestEventStreamChanBlocksHandler verifies that EventStreamChan blocks until
// the event channel is closed.
func TestEventStreamChanBlocksHandler(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
c, _ := CreateTestContextWithRequest(rr, req)
handlerReturned := make(chan struct{})
eventChan := make(chan Event)
// Start producer goroutine before EventStreamChan blocks
go func() {
defer close(eventChan)
time.Sleep(30 * time.Millisecond)
eventChan <- Event{Data: "hello"}
time.Sleep(30 * time.Millisecond)
}()
go func() {
c.EventStreamChan(eventChan)
close(handlerReturned)
}()
// Wait for goroutine to start
time.Sleep(10 * time.Millisecond)
// Handler should NOT have returned (eventChan not closed)
select {
case <-handlerReturned:
t.Fatal("Handler returned before eventChan was closed - EventStreamChan is not blocking")
case <-time.After(40 * time.Millisecond):
// good, still blocking
}
// Wait for producer to finish (30+30ms + margin)
select {
case <-handlerReturned:
// good, handler returned
case <-time.After(200 * time.Millisecond):
t.Fatal("Handler did not return after eventChan was closed")
}
}
// TestEventStreamChanUnblocksOnClientDisconnect verifies the handler returns
// when the request context is cancelled, even if eventChan is never closed.
func TestEventStreamChanUnblocksOnClientDisconnect(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil).WithContext(ctx)
c, _ := CreateTestContextWithRequest(rr, req)
eventChan := make(chan Event)
handlerReturned := make(chan struct{})
// Producer never closes eventChan
go func() {
for {
select {
case <-ctx.Done():
return
case eventChan <- Event{Data: "tick"}:
time.Sleep(10 * time.Millisecond)
}
}
}()
go func() {
c.EventStreamChan(eventChan)
close(handlerReturned)
}()
// Handler should NOT have returned
select {
case <-handlerReturned:
t.Fatal("Handler returned before stream ended")
case <-time.After(60 * time.Millisecond):
// good, still blocked
}
// Cancel context to simulate client disconnect
cancel()
select {
case <-handlerReturned:
// good
case <-time.After(200 * time.Millisecond):
t.Fatal("Handler did not return after client disconnect")
}
}
// TestEventStreamChanWritesEvents verifies the SSE event format is correct.
func TestEventStreamChanWritesEvents(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
c, _ := CreateTestContextWithRequest(rr, req)
eventChan := make(chan Event)
go func() {
defer close(eventChan)
eventChan <- Event{Id: "1", Event: "tick", Data: "hello\nworld"}
eventChan <- Event{Id: "2", Data: "second"}
}()
c.EventStreamChan(eventChan)
body := rr.Body.String()
ct := rr.Header().Get("Content-Type")
if !strings.Contains(ct, "text/event-stream") {
t.Fatalf("expected text/event-stream content type, got %q", ct)
}
if !strings.Contains(body, "id: 1") {
t.Fatal("missing id field in first event")
}
if !strings.Contains(body, "event: tick") {
t.Fatal("missing event field in first event")
}
if !strings.Contains(body, "data: hello") {
t.Fatal("missing data line 1 in first event")
}
if !strings.Contains(body, "data: world") {
t.Fatal("missing data line 2 in first event")
}
if !strings.Contains(body, "id: 2") {
t.Fatal("missing id field in second event")
}
if !strings.Contains(body, "data: second") {
t.Fatal("missing data in second event")
}
}