package infra import ( "context" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // nonFlushWriter es un http.ResponseWriter que NO implementa http.Flusher, // usado para validar el path de error de SSEHandler. type nonFlushWriter struct { header http.Header body []byte status int } func (n *nonFlushWriter) Header() http.Header { return n.header } func (n *nonFlushWriter) Write(b []byte) (int, error) { n.body = append(n.body, b...) return len(b), nil } func (n *nonFlushWriter) WriteHeader(s int) { n.status = s } // flushRecorder es un httptest.ResponseRecorder que tambien implementa http.Flusher // para que las funciones SSE puedan flushear sin perder los bytes en buffer. type flushRecorder struct { *httptest.ResponseRecorder flushes int32 } func newFlushRecorder() *flushRecorder { return &flushRecorder{ResponseRecorder: httptest.NewRecorder()} } func (f *flushRecorder) Flush() { atomic.AddInt32(&f.flushes, 1) } func (f *flushRecorder) FlushCount() int { return int(atomic.LoadInt32(&f.flushes)) } // --- SSESend --- func TestSSESend(t *testing.T) { t.Run("serializa data simple sin event ni id", func(t *testing.T) { rec := newFlushRecorder() err := SSESend(rec, SSEEvent{Data: "hola"}) if err != nil { t.Fatalf("unexpected error: %v", err) } body := rec.Body.String() want := "data: hola\n\n" if body != want { t.Errorf("got body %q, want %q", body, want) } }) t.Run("incluye event cuando esta presente", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{Event: "tick", Data: "1"}) body := rec.Body.String() if !strings.HasPrefix(body, "event: tick\n") { t.Errorf("body should start with 'event: tick\\n', got %q", body) } }) t.Run("incluye id cuando esta presente", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{ID: "42", Data: "x"}) if !strings.Contains(rec.Body.String(), "id: 42\n") { t.Errorf("body should contain 'id: 42\\n', got %q", rec.Body.String()) } }) t.Run("incluye retry cuando es positivo", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{Retry: 5000, Data: "x"}) if !strings.Contains(rec.Body.String(), "retry: 5000\n") { t.Errorf("body should contain 'retry: 5000\\n', got %q", rec.Body.String()) } }) t.Run("omite retry cuando es 0", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{Retry: 0, Data: "x"}) if strings.Contains(rec.Body.String(), "retry:") { t.Errorf("body should NOT contain 'retry:', got %q", rec.Body.String()) } }) t.Run("data multilinea genera multiples lineas data:", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{Data: "linea1\nlinea2\nlinea3"}) body := rec.Body.String() for _, want := range []string{"data: linea1\n", "data: linea2\n", "data: linea3\n"} { if !strings.Contains(body, want) { t.Errorf("body should contain %q, got %q", want, body) } } }) t.Run("hace flush si el writer es Flusher", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{Data: "x"}) if rec.FlushCount() != 1 { t.Errorf("expected 1 flush, got %d", rec.FlushCount()) } }) t.Run("termina cada evento con doble salto de linea", func(t *testing.T) { rec := newFlushRecorder() _ = SSESend(rec, SSEEvent{Event: "x", Data: "y"}) body := rec.Body.String() if !strings.HasSuffix(body, "\n\n") { t.Errorf("body should end with '\\n\\n', got %q", body) } }) } // --- SSEKeepalive --- func TestSSEKeepalive(t *testing.T) { t.Run("escribe comentario keepalive periodicamente", func(t *testing.T) { rec := newFlushRecorder() done := make(chan struct{}) go SSEKeepalive(rec, 10*time.Millisecond, done) time.Sleep(50 * time.Millisecond) close(done) time.Sleep(20 * time.Millisecond) body := rec.Body.String() if !strings.Contains(body, ": keepalive\n\n") { t.Errorf("body should contain ': keepalive\\n\\n', got %q", body) } count := strings.Count(body, ": keepalive\n\n") if count < 2 { t.Errorf("expected at least 2 keepalives, got %d", count) } }) t.Run("termina cuando done se cierra", func(t *testing.T) { rec := newFlushRecorder() done := make(chan struct{}) finished := make(chan struct{}) go func() { SSEKeepalive(rec, 5*time.Millisecond, done) close(finished) }() close(done) select { case <-finished: // ok case <-time.After(100 * time.Millisecond): t.Error("SSEKeepalive did not return after done was closed") } }) } // --- SSEHandler --- func TestSSEHandler(t *testing.T) { t.Run("setea headers SSE correctos", func(t *testing.T) { events := make(chan SSEEvent) handler := SSEHandler(events) // Lanzar handler en goroutine y cerrar canal para que retorne go func() { time.Sleep(20 * time.Millisecond) close(events) }() ts := httptest.NewServer(handler) defer ts.Close() resp, err := http.Get(ts.URL) if err != nil { t.Fatalf("get failed: %v", err) } defer resp.Body.Close() if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { t.Errorf("Content-Type = %q, want text/event-stream", got) } if got := resp.Header.Get("Cache-Control"); got != "no-cache" { t.Errorf("Cache-Control = %q, want no-cache", got) } }) t.Run("envia eventos del canal al writer", func(t *testing.T) { events := make(chan SSEEvent, 2) events <- SSEEvent{Event: "first", Data: "1"} events <- SSEEvent{Event: "second", Data: "2"} close(events) handler := SSEHandler(events) ts := httptest.NewServer(handler) defer ts.Close() resp, err := http.Get(ts.URL) if err != nil { t.Fatalf("get failed: %v", err) } defer resp.Body.Close() buf := make([]byte, 4096) n, _ := resp.Body.Read(buf) body := string(buf[:n]) for _, want := range []string{"event: first", "event: second", "data: 1", "data: 2"} { if !strings.Contains(body, want) { t.Errorf("body should contain %q, got %q", want, body) } } }) t.Run("termina si el contexto del request se cancela", func(t *testing.T) { events := make(chan SSEEvent) handler := SSEHandler(events) ts := httptest.NewServer(handler) defer ts.Close() ctx, cancel := context.WithCancel(context.Background()) req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL, nil) done := make(chan struct{}) go func() { resp, err := http.DefaultClient.Do(req) if err == nil { resp.Body.Close() } close(done) }() time.Sleep(20 * time.Millisecond) cancel() select { case <-done: // handler debe haber retornado tras la cancelacion del cliente case <-time.After(2 * time.Second): t.Error("handler did not return after context cancel") } }) t.Run("retorna 500 si el writer no es Flusher", func(t *testing.T) { // nonFlushWriter no implementa Flusher events := make(chan SSEEvent) handler := SSEHandler(events) rec := &nonFlushWriter{header: http.Header{}} req := httptest.NewRequest("GET", "/", nil) handler(rec, req) if rec.status != http.StatusInternalServerError { t.Errorf("got status %d, want 500", rec.status) } }) }