Test / test (push) Failing after 25m47s
Go port of the read path of pybyd 0.0.75 (the client behind jkaberg/hass-byd-vehicle): the bangcle white-box AES envelope, the signing/checkcode layer, login, vehicle list, and realtime readings. Scraping runs on a cron schedule rather than per Prometheus request because each reading wakes the car's T-Box and drains the traction battery; /metrics serves the last result. Every numeric field of the realtime payload becomes a gauge, so the exporter does not need a hand-maintained field list that goes stale when BYD changes the payload. Tests pin the wire format against pybyd byte-for-byte and drive the client end to end against a fake cloud that speaks the real protocol. Co-Authored-By: Claude Opus 5 <[email protected]>
171 lines
5.0 KiB
Go
171 lines
5.0 KiB
Go
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: "[email protected]", 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("[email protected]")
|
|
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()
|
|
}
|
|
}
|
|
}
|