ahora si funciona

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:23:52 +02:00
parent d996542f88
commit 10bfb846a8
16 changed files with 1530 additions and 0 deletions
@@ -0,0 +1,142 @@
package infra
import "testing"
// findSample devuelve el primer sample con el nombre dado, o nil si no existe.
func findSample(samples []PromSample, name string) *PromSample {
for i := range samples {
if samples[i].Name == name {
return &samples[i]
}
}
return nil
}
func TestCollectBatteryMetrics_ParseDischarging(t *testing.T) {
in := []byte(`{"health":"GOOD","percentage":85,"plugged":"UNPLUGGED","status":"DISCHARGING","temperature":28.9,"current":-350000}`)
samples, err := parseBatteryJSON(in)
if err != nil {
t.Fatalf("parseBatteryJSON returned error: %v", err)
}
t.Run("percent", func(t *testing.T) {
s := findSample(samples, "node_battery_percent")
if s == nil {
t.Fatal("missing node_battery_percent")
}
if s.Value != 85 {
t.Errorf("got percent %v, want 85", s.Value)
}
})
t.Run("temp", func(t *testing.T) {
s := findSample(samples, "node_battery_temp_celsius")
if s == nil {
t.Fatal("missing node_battery_temp_celsius")
}
if s.Value != 28.9 {
t.Errorf("got temp %v, want 28.9", s.Value)
}
})
t.Run("charging zero when discharging and unplugged", func(t *testing.T) {
s := findSample(samples, "node_battery_charging")
if s == nil {
t.Fatal("missing node_battery_charging")
}
if s.Value != 0 {
t.Errorf("got charging %v, want 0", s.Value)
}
})
t.Run("current", func(t *testing.T) {
s := findSample(samples, "node_battery_current_ua")
if s == nil {
t.Fatal("missing node_battery_current_ua")
}
if s.Value != -350000 {
t.Errorf("got current %v, want -350000", s.Value)
}
})
t.Run("health info series with labels", func(t *testing.T) {
s := findSample(samples, "node_battery_health_info")
if s == nil {
t.Fatal("missing node_battery_health_info")
}
if s.Value != 1 {
t.Errorf("got health_info value %v, want 1", s.Value)
}
if s.Labels["health"] != "GOOD" {
t.Errorf("got health label %q, want GOOD", s.Labels["health"])
}
if s.Labels["status"] != "DISCHARGING" {
t.Errorf("got status label %q, want DISCHARGING", s.Labels["status"])
}
if s.Labels["plugged"] != "UNPLUGGED" {
t.Errorf("got plugged label %q, want UNPLUGGED", s.Labels["plugged"])
}
})
}
func TestCollectBatteryMetrics_ParseCharging(t *testing.T) {
in := []byte(`{"health":"GOOD","percentage":60,"plugged":"PLUGGED_AC","status":"CHARGING","temperature":31.2,"current":420000}`)
samples, err := parseBatteryJSON(in)
if err != nil {
t.Fatalf("parseBatteryJSON returned error: %v", err)
}
s := findSample(samples, "node_battery_charging")
if s == nil {
t.Fatal("missing node_battery_charging")
}
if s.Value != 1 {
t.Errorf("got charging %v, want 1 (status CHARGING)", s.Value)
}
}
func TestCollectBatteryMetrics_ParsePluggedNotUnplugged(t *testing.T) {
// status no es CHARGING/FULL pero plugged != UNPLUGGED -> charging = 1.
in := []byte(`{"health":"GOOD","percentage":100,"plugged":"PLUGGED_USB","status":"NOT_CHARGING","temperature":30.0,"current":0}`)
samples, err := parseBatteryJSON(in)
if err != nil {
t.Fatalf("parseBatteryJSON returned error: %v", err)
}
s := findSample(samples, "node_battery_charging")
if s == nil {
t.Fatal("missing node_battery_charging")
}
if s.Value != 1 {
t.Errorf("got charging %v, want 1 (plugged != UNPLUGGED)", s.Value)
}
}
func TestCollectBatteryMetrics_ParseFull(t *testing.T) {
in := []byte(`{"health":"GOOD","percentage":100,"plugged":"UNPLUGGED","status":"FULL","temperature":29.5,"current":0}`)
samples, err := parseBatteryJSON(in)
if err != nil {
t.Fatalf("parseBatteryJSON returned error: %v", err)
}
s := findSample(samples, "node_battery_charging")
if s == nil {
t.Fatal("missing node_battery_charging")
}
if s.Value != 1 {
t.Errorf("got charging %v, want 1 (status FULL)", s.Value)
}
}
func TestCollectBatteryMetrics_InvalidJSON(t *testing.T) {
in := []byte(`not a json at all`)
_, err := parseBatteryJSON(in)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
@@ -0,0 +1,43 @@
package infra
import "testing"
func TestCollectHostMetrics_ReturnsBasics(t *testing.T) {
samples, err := CollectHostMetrics()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(samples) == 0 {
t.Fatal("expected at least one sample")
}
// node_uptime_seconds es el unico colector imprescindible: debe estar siempre.
found := false
for _, s := range samples {
if s.Name == "node_uptime_seconds" {
found = true
if s.Value <= 0 {
t.Errorf("node_uptime_seconds should be positive, got %v", s.Value)
}
}
}
if !found {
t.Error("node_uptime_seconds not present in samples")
}
}
func TestCollectHostMetrics_SamplesWellFormed(t *testing.T) {
samples, err := CollectHostMetrics()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, s := range samples {
if s.Name == "" {
t.Errorf("sample %d has empty Name", i)
}
// La label "instance" NO debe estar: la añade el pusher.
if _, ok := s.Labels["instance"]; ok {
t.Errorf("sample %d (%s) must not carry the instance label", i, s.Name)
}
}
}
@@ -0,0 +1,74 @@
package infra
import "testing"
func TestFormatPromExposition(t *testing.T) {
t.Run("varias series con y sin labels con timestamp", func(t *testing.T) {
samples := []PromSample{
{Name: "node_load1", Value: 0.42},
{Name: "node_cpu_core_percent", Labels: map[string]string{"core": "0"}, Value: 12.5},
{Name: "node_disk_used_bytes", Labels: map[string]string{"mount": "/"}, Value: 1024},
}
got := FormatPromExposition(samples, 1700000000000)
want := "node_load1 0.42 1700000000000\n" +
"node_cpu_core_percent{core=\"0\"} 12.5 1700000000000\n" +
"node_disk_used_bytes{mount=\"/\"} 1024 1700000000000\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
})
t.Run("sin timestamp omite el campo", func(t *testing.T) {
samples := []PromSample{
{Name: "node_load1", Value: 0.42},
{Name: "node_cpu_percent", Value: 3},
}
got := FormatPromExposition(samples, 0)
want := "node_load1 0.42\n" +
"node_cpu_percent 3\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
})
t.Run("labels ordenadas por clave deterministico", func(t *testing.T) {
samples := []PromSample{
{Name: "node_proc_cpu_percent", Labels: map[string]string{"pid": "42", "name": "claude"}, Value: 7.5},
}
got := FormatPromExposition(samples, 0)
// "name" antes que "pid" alfabeticamente.
want := "node_proc_cpu_percent{name=\"claude\",pid=\"42\"} 7.5\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
})
t.Run("escapa backslash comilla y newline en valor de label", func(t *testing.T) {
samples := []PromSample{
{Name: "node_proc_cpu_percent", Labels: map[string]string{"name": "a\\b\"c\nd"}, Value: 1},
}
got := FormatPromExposition(samples, 0)
want := "node_proc_cpu_percent{name=\"a\\\\b\\\"c\\nd\"} 1\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
})
t.Run("sanitiza nombre de metrica invalido", func(t *testing.T) {
samples := []PromSample{
{Name: "node.cpu-percent!", Value: 5},
}
got := FormatPromExposition(samples, 0)
want := "node_cpu_percent_ 5\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
})
t.Run("slice vacio produce string vacio", func(t *testing.T) {
got := FormatPromExposition(nil, 1700000000000)
if got != "" {
t.Errorf("got %q, want empty string", got)
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package infra
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// lokiPushBody refleja la estructura JSON que espera el push API de Loki.
type lokiPushBody struct {
Streams []struct {
Stream map[string]string `json:"stream"`
Values [][2]string `json:"values"`
} `json:"streams"`
}
func TestPushLokiStream(t *testing.T) {
t.Run("JSON enviado tiene estructura streams/stream/values correcta", func(t *testing.T) {
var captured lokiPushBody
var contentType string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType = r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(raw, &captured); err != nil {
t.Errorf("body no es JSON valido: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
labels := map[string]string{"instance": "lucas", "job": "journald", "unit": "ssh.service"}
ts := []int64{1700000000000000001, 1700000000000000002}
lines := []string{"line one", "line two"}
err := PushLokiStream(srv.URL, "", "", labels, ts, lines)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
if len(captured.Streams) != 1 {
t.Fatalf("streams len = %d, want 1", len(captured.Streams))
}
s := captured.Streams[0]
if s.Stream["unit"] != "ssh.service" || s.Stream["job"] != "journald" || s.Stream["instance"] != "lucas" {
t.Errorf("stream labels = %v, want %v", s.Stream, labels)
}
if len(s.Values) != 2 {
t.Fatalf("values len = %d, want 2", len(s.Values))
}
if s.Values[0][0] != "1700000000000000001" || s.Values[0][1] != "line one" {
t.Errorf("values[0] = %v, want [1700000000000000001 line one]", s.Values[0])
}
if s.Values[1][0] != "1700000000000000002" || s.Values[1][1] != "line two" {
t.Errorf("values[1] = %v, want [1700000000000000002 line two]", s.Values[1])
}
})
t.Run("longitudes desiguales dan error antes del POST", func(t *testing.T) {
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
ts := []int64{1, 2, 3}
lines := []string{"only one"}
err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, ts, lines)
if err == nil {
t.Fatalf("se esperaba error por longitudes desiguales")
}
if hit {
t.Errorf("no debe haber peticion HTTP cuando las longitudes no coinciden")
}
})
t.Run("len lines cero es no-op sin peticion", func(t *testing.T) {
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, []int64{}, []string{})
if err != nil {
t.Fatalf("no-op no debe retornar error: %v", err)
}
if hit {
t.Errorf("no-op no debe hacer ninguna peticion HTTP")
}
})
t.Run("Basic Auth presente cuando user no vacio", func(t *testing.T) {
var gotUser, gotPass string
var hadAuth bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, gotPass, hadAuth = r.BasicAuth()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
err := PushLokiStream(srv.URL, "tenant", "secret", map[string]string{"job": "x"}, []int64{1}, []string{"hi"})
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if !hadAuth {
t.Fatalf("se esperaba header Authorization Basic")
}
if gotUser != "tenant" || gotPass != "secret" {
t.Errorf("basic auth = %q/%q, want tenant/secret", gotUser, gotPass)
}
})
t.Run("status 500 produce error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("loki rejected the push"))
}))
defer srv.Close()
err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, []int64{1}, []string{"hi"})
if err == nil {
t.Fatalf("se esperaba error con status 500")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error no menciona el codigo 500: %v", err)
}
if !strings.Contains(err.Error(), "loki rejected the push") {
t.Errorf("error no incluye el cuerpo de respuesta: %v", err)
}
})
}
+111
View File
@@ -0,0 +1,111 @@
package infra
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPushPromRemote(t *testing.T) {
t.Run("body llega completo y status 204 es exito", func(t *testing.T) {
const body = "node_load1 0.42 1700000000000\nnode_cpu_percent 3\n"
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
if ct := r.Header.Get("Content-Type"); ct != "text/plain" {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
if err := PushPromRemote(srv.URL, "", "", body, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotBody != body {
t.Errorf("body got %q, want %q", gotBody, body)
}
})
t.Run("basic auth presente cuando user no vacio", func(t *testing.T) {
var hadAuth bool
var gotUser, gotPass string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, gotPass, hadAuth = r.BasicAuth()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
if err := PushPromRemote(srv.URL, "alice", "s3cr3t", "x 1\n", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hadAuth {
t.Fatal("expected Authorization Basic header, got none")
}
if gotUser != "alice" || gotPass != "s3cr3t" {
t.Errorf("basic auth = %q/%q, want alice/s3cr3t", gotUser, gotPass)
}
})
t.Run("sin user no manda Authorization", func(t *testing.T) {
var hadAuth bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _, hadAuth = r.BasicAuth()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
if err := PushPromRemote(srv.URL, "", "ignored", "x 1\n", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hadAuth {
t.Error("expected no Authorization header when user is empty")
}
})
t.Run("extra_label aparece en la query", func(t *testing.T) {
var gotQuery []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()["extra_label"]
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
labels := map[string]string{"instance": "lucas", "region": "eu"}
if err := PushPromRemote(srv.URL, "", "", "x 1\n", labels); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(gotQuery) != 2 {
t.Fatalf("got %d extra_label params, want 2: %v", len(gotQuery), gotQuery)
}
joined := strings.Join(gotQuery, ",")
if !strings.Contains(joined, "instance=lucas") {
t.Errorf("extra_label missing instance=lucas: %v", gotQuery)
}
if !strings.Contains(joined, "region=eu") {
t.Errorf("extra_label missing region=eu: %v", gotQuery)
}
})
t.Run("status 500 produce error con codigo y snippet", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, "boom: bad input")
}))
defer srv.Close()
err := PushPromRemote(srv.URL, "", "", "x 1\n", nil)
if err == nil {
t.Fatal("expected error on status 500, got nil")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error should mention status 500: %v", err)
}
if !strings.Contains(err.Error(), "boom") {
t.Errorf("error should include response body snippet: %v", err)
}
})
}