ahora si funciona
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user