package infra import ( "context" "encoding/binary" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" ) // buildDockerFrame construye un frame del protocolo de multiplexion de Docker. // streamType: 1=stdout, 2=stderr. payload: contenido (puede incluir newline). func buildDockerFrame(streamType uint8, payload string) []byte { data := []byte(payload) frame := make([]byte, 8+len(data)) frame[0] = streamType binary.BigEndian.PutUint32(frame[4:8], uint32(len(data))) copy(frame[8:], data) return frame } // buildMultiFrame concatena multiples frames en un unico slice de bytes. func buildMultiFrame(frames ...[]byte) []byte { var buf []byte for _, f := range frames { buf = append(buf, f...) } return buf } func TestDockerContainerLogs_Snapshot(t *testing.T) { t.Run("snapshot stdout y stderr demuxeados", func(t *testing.T) { body := buildMultiFrame( buildDockerFrame(1, "linea stdout 1\n"), buildDockerFrame(2, "linea stderr 1\n"), buildDockerFrame(1, "linea stdout 2\n"), ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/containers/") { t.Errorf("path inesperado: %s", r.URL.Path) } w.WriteHeader(http.StatusOK) w.Write(body) })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "test-container", Tail: 10, Stdout: true, Stderr: true, DockerHost: "tcp://" + srv.Listener.Addr().String(), } lines, err := DockerContainerLogs(opts) if err != nil { t.Fatalf("DockerContainerLogs error: %v", err) } if len(lines) != 3 { t.Fatalf("esperadas 3 lineas, got %d", len(lines)) } if lines[0].Stream != "stdout" { t.Errorf("linea[0].Stream = %q, want stdout", lines[0].Stream) } if lines[0].Line != "linea stdout 1" { t.Errorf("linea[0].Line = %q, want 'linea stdout 1'", lines[0].Line) } if lines[1].Stream != "stderr" { t.Errorf("linea[1].Stream = %q, want stderr", lines[1].Stream) } if lines[1].Line != "linea stderr 1" { t.Errorf("linea[1].Line = %q, want 'linea stderr 1'", lines[1].Line) } if lines[2].Stream != "stdout" { t.Errorf("linea[2].Stream = %q, want stdout", lines[2].Stream) } }) t.Run("container no encontrado retorna error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"message":"No such container: missing"}`)) })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "missing", DockerHost: "tcp://" + srv.Listener.Addr().String(), } _, err := DockerContainerLogs(opts) if err == nil { t.Fatal("esperaba error para container no encontrado") } if !strings.Contains(err.Error(), "missing") { t.Errorf("error no menciona el container: %v", err) } }) t.Run("timestamps parseados del prefijo Docker", func(t *testing.T) { // Docker prefija: "2026-05-23T12:00:00.000000000Z texto\n" payload := "2026-05-23T12:00:00.000000000Z hello timestamps\n" body := buildDockerFrame(1, payload) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("timestamps") != "1" { t.Errorf("timestamps param no enviado, query: %s", r.URL.RawQuery) } w.WriteHeader(http.StatusOK) w.Write(body) })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "ts-container", Stdout: true, Timestamps: true, DockerHost: "tcp://" + srv.Listener.Addr().String(), } lines, err := DockerContainerLogs(opts) if err != nil { t.Fatalf("error: %v", err) } if len(lines) != 1 { t.Fatalf("esperada 1 linea, got %d", len(lines)) } if lines[0].Timestamp != "2026-05-23T12:00:00.000000000Z" { t.Errorf("Timestamp = %q, want RFC3339", lines[0].Timestamp) } if lines[0].Line != "hello timestamps" { t.Errorf("Line = %q, want 'hello timestamps'", lines[0].Line) } }) t.Run("tail y since se envian como query params", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if q.Get("tail") != "50" { t.Errorf("tail = %q, want '50'", q.Get("tail")) } if q.Get("since") != "10m" { t.Errorf("since = %q, want '10m'", q.Get("since")) } w.WriteHeader(http.StatusOK) })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "c1", Tail: 50, Since: "10m", Stdout: true, DockerHost: "tcp://" + srv.Listener.Addr().String(), } lines, err := DockerContainerLogs(opts) if err != nil { t.Fatalf("error: %v", err) } if len(lines) != 0 { t.Errorf("esperadas 0 lineas de body vacio, got %d", len(lines)) } }) } func TestDockerContainerLogsStream(t *testing.T) { t.Run("streaming recibe lineas via callback", func(t *testing.T) { frames := buildMultiFrame( buildDockerFrame(1, "stream line 1\n"), buildDockerFrame(2, "stream line 2\n"), buildDockerFrame(1, "stream line 3\n"), ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("follow") != "1" { t.Errorf("follow param no enviado") } w.WriteHeader(http.StatusOK) w.Write(frames) })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "stream-container", Stdout: true, Stderr: true, DockerHost: "tcp://" + srv.Listener.Addr().String(), } var received []DockerLogLine ctx := context.Background() err := DockerContainerLogsStream(ctx, opts, func(line DockerLogLine) error { received = append(received, line) return nil }) if err != nil { t.Fatalf("DockerContainerLogsStream error: %v", err) } if len(received) != 3 { t.Fatalf("esperadas 3 lineas, got %d", len(received)) } if received[0].Line != "stream line 1" { t.Errorf("received[0].Line = %q", received[0].Line) } if received[1].Stream != "stderr" { t.Errorf("received[1].Stream = %q, want stderr", received[1].Stream) } }) t.Run("ctx cancel detiene el stream", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) flusher := w.(http.Flusher) w.Write(buildDockerFrame(1, "antes del cancel\n")) flusher.Flush() // Bloquear hasta que el cliente cierre la conexion. <-r.Context().Done() })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "cancel-container", Stdout: true, DockerHost: "tcp://" + srv.Listener.Addr().String(), } ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() var count int err := DockerContainerLogsStream(ctx, opts, func(line DockerLogLine) error { count++ return nil }) if err == nil { t.Error("esperaba error de cancelacion de contexto") } if count == 0 { t.Error("esperaba recibir al menos 1 linea antes del cancel") } }) t.Run("callback error cancela el stream", func(t *testing.T) { frames := buildMultiFrame( buildDockerFrame(1, "linea 1\n"), buildDockerFrame(1, "linea 2\n"), buildDockerFrame(1, "linea 3\n"), ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write(frames) })) defer srv.Close() opts := DockerLogsOpts{ ContainerID: "cb-error-container", Stdout: true, DockerHost: "tcp://" + srv.Listener.Addr().String(), } stopErr := errors.New("stop processing") var count int err := DockerContainerLogsStream(context.Background(), opts, func(line DockerLogLine) error { count++ if count >= 2 { return stopErr } return nil }) if !errors.Is(err, stopErr) { t.Errorf("esperaba stopErr, got: %v", err) } if count < 2 { t.Errorf("esperaba al menos 2 invocaciones del callback, got %d", count) } if count > 3 { t.Errorf("callback invocado demasiadas veces tras error: %d", count) } }) } func TestDockerDemuxFrame(t *testing.T) { t.Run("frame stdout decodificado correctamente", func(t *testing.T) { payload := "hello world" frame := buildDockerFrame(1, payload) r := strings.NewReader(string(frame)) streamType, data, err := dockerDemuxFrame(r) if err != nil { t.Fatalf("error: %v", err) } if streamType != 1 { t.Errorf("streamType = %d, want 1", streamType) } if string(data) != payload { t.Errorf("payload = %q, want %q", string(data), payload) } }) t.Run("frame stderr decodificado correctamente", func(t *testing.T) { frame := buildDockerFrame(2, "error line") r := strings.NewReader(string(frame)) streamType, _, err := dockerDemuxFrame(r) if err != nil { t.Fatalf("error: %v", err) } if streamType != 2 { t.Errorf("streamType = %d, want 2 (stderr)", streamType) } }) }