package exporter import ( "context" "io" "log" "net/http" "net/http/httptest" "strings" "testing" "time" "git.tinetunnel.com/thistine/byd-exporter/internal/byd" "git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest" ) func newExporter(t *testing.T, baseURL string) *Exporter { t.Helper() client, err := byd.New(byd.Config{ Username: "user@example.com", Password: bydtest.Password, BaseURL: baseURL, CountryCode: "NL", Language: "en", TimeZone: "UTC", AppName: "pyBYD+0.0.75", AppVersion: "3.5.1", AppInnerVersion: "351", SoftType: "0", TboxVersion: "3", IsAuto: "1", SessionTTL: time.Hour, PollAttempts: 3, PollInterval: time.Millisecond, Device: mustDevice(t), }) if err != nil { t.Fatal(err) } return New(client, log.New(io.Discard, "", 0)) } func mustDevice(t *testing.T) byd.Device { t.Helper() d, err := byd.NewDevice("user@example.com") if err != nil { t.Fatal(err) } return d } // TestScrapeEndToEnd runs login, vehicle list, realtime and rendering against a // fake cloud that speaks the real protocol. func TestScrapeEndToEnd(t *testing.T) { srv := bydtest.New(t) exp := newExporter(t, srv.URL) if err := exp.Scrape(context.Background()); err != nil { t.Fatalf("Scrape: %v", err) } got := exp.Metrics() wantLines := []string{ `byd_elec_percent{name="Daily",vin="` + bydtest.VIN + `"} 82`, `byd_endurance_mileage{name="Daily",vin="` + bydtest.VIN + `"} 391`, `byd_total_mileage{name="Daily",vin="` + bydtest.VIN + `"} 14203`, `byd_left_front_tirepressure{name="Daily",vin="` + bydtest.VIN + `"} 240`, `byd_vehicle_up{name="Daily",vin="` + bydtest.VIN + `"} 1`, `byd_vehicles 1`, `byd_scrape_success 1`, } for _, line := range wantLines { if !strings.Contains(got, line) { t.Errorf("missing line %q in:\n%s", line, got) } } if !strings.Contains(got, `byd_vehicle_info{brand="BYD",model="SEALION 7",name="Daily",plate="AB-1234",vin="`) { t.Errorf("byd_vehicle_info labels are wrong:\n%s", got) } // The VIN is a string field of the payload; it must not become a metric. if strings.Contains(got, "byd_vin") { t.Errorf("string field leaked into a metric:\n%s", got) } } // TestScrapeFailurePublishesZero: a broken scrape must still publish, so the // failure is visible in Prometheus rather than hiding behind stale numbers. func TestScrapeFailurePublishesZero(t *testing.T) { dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "upstream is down", http.StatusBadGateway) })) defer dead.Close() exp := newExporter(t, dead.URL) if err := exp.Scrape(context.Background()); err == nil { t.Fatal("expected an error from a failing upstream") } got := exp.Metrics() if !strings.Contains(got, "byd_scrape_success 0") { t.Errorf("expected byd_scrape_success 0, got:\n%s", got) } if !strings.Contains(got, "byd_scrape_timestamp_seconds") { t.Errorf("expected a timestamp even on failure, got:\n%s", got) } } // TestScrapeSurvivesOneBadVehicle: one car that cannot be read must not cost // the metrics of the others. func TestScrapeSurvivesOneBadVehicle(t *testing.T) { srv := bydtest.New(t) srv.Vehicles = append(bydtest.DefaultVehicles(), map[string]any{ "vin": "", // skipped: no VIN to label with "modelName": "Ghost", }) exp := newExporter(t, srv.URL) if err := exp.Scrape(context.Background()); err != nil { t.Fatalf("Scrape: %v", err) } got := exp.Metrics() if !strings.Contains(got, "byd_vehicles 2") { t.Errorf("expected both vehicles counted:\n%s", got) } if strings.Contains(got, `vin=""`) { t.Errorf("a vehicle with no VIN should be skipped:\n%s", got) } if !strings.Contains(got, "byd_elec_percent") { t.Errorf("the good vehicle's metrics went missing:\n%s", got) } } func TestServeHTTP(t *testing.T) { srv := bydtest.New(t) exp := newExporter(t, srv.URL) // Before any scrape the body is empty but the request must still succeed, // so Prometheus sees an up target rather than a connection error. rec := httptest.NewRecorder() exp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { t.Errorf("Content-Type = %q, want text/plain", ct) } if err := exp.Scrape(context.Background()); err != nil { t.Fatal(err) } rec = httptest.NewRecorder() exp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if !strings.Contains(rec.Body.String(), "byd_elec_percent") { t.Errorf("body has no metrics:\n%s", rec.Body.String()) } } // TestConcurrentServeAndScrape is a race-detector target: /metrics is served // while a scrape is replacing it. func TestConcurrentServeAndScrape(t *testing.T) { srv := bydtest.New(t) exp := newExporter(t, srv.URL) done := make(chan struct{}) go func() { defer close(done) for i := 0; i < 3; i++ { exp.Scrape(context.Background()) } }() for { select { case <-done: return default: _ = exp.Metrics() } } }