package byd import ( "context" "errors" "strings" "testing" "time" "git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest" ) // testDevice is the fingerprint the pybyd reference payload was generated with. var testDevice = Device{ OSType: "and", IMEI: "356271112345674", MAC: "02:11:22:33:44:55", Model: "SM-S928B", SDK: "35", Mod: "samsung", IMEIMD5: "deadbeefdeadbeefdeadbeefdeadbeef", MobileBrand: "SAMSUNG", MobileModel: "SM-S928B", DeviceType: "0", NetworkType: "wifi", AndroidVer: "15", OSVersion: "35", } func testConfig(baseURL string) Config { return 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: 5, PollInterval: time.Millisecond, Device: testDevice, } } // newTestClient freezes the clock and the nonce so payloads are reproducible. func newTestClient(t *testing.T, cfg Config) *Client { t.Helper() c, err := New(cfg) if err != nil { t.Fatal(err) } c.now = func() time.Time { return time.UnixMilli(1700000000000) } c.nonce = func() string { return strings.Repeat("0", 32) } return c } // TestLoginRequestMatchesPybyd compares a complete login payload — field order, // encryData, sign and checkcode — against pybyd's build_login_request run with // the same frozen clock and nonce. This is what catches a renamed, missing or // misordered field, which the primitive tests in crypto_test.go cannot see. func TestLoginRequestMatchesPybyd(t *testing.T) { const want = `{"appName":"pyBYD+0.0.75","countryCode":"NL","encryData":"2F01666A8BC0E6DBD65D2CB5EDF6AA1D9CD01D40F48A5EDE699779B21E399C5223841BD789B595E1BE40C5A0BCA07A26A0B649AB6DD3E63BC4F112D71E0EC4A904312DBF56DEBA50F407B678C1A1E8FD8AFC4F76520AD7D9EB03D9C430160C787C209DE5B38133CFBD9123E4F935E86A42EE732731F336B740BFDB206EEC7A26D756B1CF5ADF20F595532810A6019F5A717C21475DBDBA5F5415E0D4CA1AD26433761852FEE204DA496E2C775EF22C30B5EEBDCBEA164D9518F2345EC5DE5E70A6CC22B8D18B2B7BB9AEED652F2D123B2D96E5787C0EA39E20DBE529A2A4EB4EC51899D5A3CB2AEC8FF1EA1561CBA7D347C82CC075A4ECD92A48549F5492D4B4C0E487F3A45F8C7060880EF881254DBA578FAE4CBE8AA8777EFF7A26272E61867ABB33ECB752476BBB69A257CFA57C4C6E1269DA5EDFED322F13202ADC28B318C8868DFEDF3138E0A6655BB7B037480613975F5A437A698138DEA7DD26DB0B0F1445DE3D6B3C3B1B15D6DEAEFF9545EB9EE29F17A1C77BD4239ACDC00310571E1C338AE783106FDFAC488A0C6E0A4659","functionType":"pwdLogin","identifier":"user@example.com","identifierType":"0","imeiMD5":"deadbeefdeadbeefdeadbeefdeadbeef","isAuto":"1","language":"en","reqTimestamp":"1700000000000","sign":"97d29Abe36be116c9A3681703Ead98ad86c0D088","signKey":"hunter2","ostype":"and","imei":"356271112345674","mac":"02:11:22:33:44:55","model":"SM-S928B","sdk":"35","mod":"samsung","serviceTime":"1700000000000","checkcode":"8ac193b5be6e01d003b5c1da9e9036d8"}` c := newTestClient(t, testConfig("http://example.invalid")) outer, err := c.loginRequest() if err != nil { t.Fatal(err) } got, err := marshalCompact(outer) if err != nil { t.Fatal(err) } if string(got) != want { t.Errorf("login payload differs from pybyd\n got %s\nwant %s", got, want) } } func TestLoginAndVehicles(t *testing.T) { srv := bydtest.New(t) c := newTestClient(t, testConfig(srv.URL)) ctx := context.Background() if err := c.EnsureSession(ctx); err != nil { t.Fatalf("login: %v", err) } // userId arrives as a JSON number; it has to survive as "42", not "4.2e+01". if c.sess.userID != "42" { t.Errorf("userID = %q, want 42", c.sess.userID) } vehicles, err := c.Vehicles(ctx) if err != nil { t.Fatalf("Vehicles: %v", err) } if len(vehicles) != 1 { t.Fatalf("got %d vehicles, want 1", len(vehicles)) } if got, want := vehicles[0].VIN, bydtest.VIN; got != want { t.Errorf("VIN = %q, want %q", got, want) } if got, want := vehicles[0].Name(), "Daily"; got != want { t.Errorf("Name = %q, want %q", got, want) } } // TestRealtimePollsUntilReady drives the trigger-then-poll flow: the trigger // only acknowledges, and the reading appears on a later poll. func TestRealtimePollsUntilReady(t *testing.T) { srv := bydtest.New(t) srv.ReadyAfterPolls = 3 c := newTestClient(t, testConfig(srv.URL)) ctx := context.Background() if err := c.EnsureSession(ctx); err != nil { t.Fatal(err) } data, err := c.Realtime(ctx, bydtest.VIN, "0") if err != nil { t.Fatalf("Realtime: %v", err) } if got := data["elecPercent"]; got != float64(82) { t.Errorf("elecPercent = %v, want 82", got) } if srv.Polls != 3 { t.Errorf("polled %d times, want 3 (stopping as soon as the car answers)", srv.Polls) } } // TestRealtimeGivesUpOnSleepingCar covers the car that never answers: the // trigger's payload comes back instead of an error, so the scrape still // produces metrics for the rest of the fleet. func TestRealtimeGivesUpOnSleepingCar(t *testing.T) { srv := bydtest.New(t) srv.ReadyAfterPolls = 1000 // never within PollAttempts cfg := testConfig(srv.URL) cfg.PollAttempts = 3 c := newTestClient(t, cfg) ctx := context.Background() if err := c.EnsureSession(ctx); err != nil { t.Fatal(err) } data, err := c.Realtime(ctx, bydtest.VIN, "0") if err != nil { t.Fatalf("Realtime errored on a sleeping car: %v", err) } if _, ok := data["requestSerial"]; !ok { t.Errorf("expected the last payload seen, got %v", data) } if srv.Polls != 3 { t.Errorf("polled %d times, want PollAttempts=3", srv.Polls) } } // TestRealtimeStopsOnCancelledContext keeps a hung scrape from outliving its // timeout: the poll loop has to watch the context, not just sleep. func TestRealtimeStopsOnCancelledContext(t *testing.T) { srv := bydtest.New(t) srv.ReadyAfterPolls = 1000 cfg := testConfig(srv.URL) cfg.PollAttempts = 1000 cfg.PollInterval = 10 * time.Millisecond c := newTestClient(t, cfg) if err := c.EnsureSession(context.Background()); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() done := make(chan struct{}) go func() { c.Realtime(ctx, bydtest.VIN, "0") close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("Realtime ignored the cancelled context") } } func TestLoginFailureReturnsAPIError(t *testing.T) { srv := bydtest.New(t) srv.LoginCode = "1003" err := newTestClient(t, testConfig(srv.URL)).Login(context.Background()) var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("got %T (%v), want *APIError", err, err) } if apiErr.Code != "1003" { t.Errorf("Code = %q, want 1003", apiErr.Code) } if !strings.Contains(apiErr.Error(), loginEndpoint) { t.Errorf("error does not name the endpoint: %v", apiErr) } } // TestEnsureSessionReusesUntilTTL: a fresh login per scrape would be both slow // and a good way to get the account flagged. func TestEnsureSessionReusesUntilTTL(t *testing.T) { srv := bydtest.New(t) cfg := testConfig(srv.URL) cfg.SessionTTL = time.Hour c := newTestClient(t, cfg) now := time.UnixMilli(1700000000000) c.now = func() time.Time { return now } ctx := context.Background() for i := 0; i < 3; i++ { if err := c.EnsureSession(ctx); err != nil { t.Fatal(err) } } if srv.Logins != 1 { t.Errorf("logged in %d times, want 1 while inside the TTL", srv.Logins) } now = now.Add(2 * time.Hour) if err := c.EnsureSession(ctx); err != nil { t.Fatal(err) } if srv.Logins != 2 { t.Errorf("logged in %d times, want a re-login once the TTL passed", srv.Logins) } } func TestEndpointsRequireLogin(t *testing.T) { c := newTestClient(t, testConfig("http://example.invalid")) if _, err := c.Vehicles(context.Background()); err == nil { t.Error("expected an error when calling an endpoint before logging in") } } func TestRealtimeReady(t *testing.T) { tests := []struct { name string info map[string]any want bool }{ {"empty", nil, false}, {"ack only", map[string]any{"requestSerial": "abc"}, false}, {"offline wins over data", map[string]any{"onlineState": 2.0, "enduranceMileage": 300.0}, false}, {"tyre pressure", map[string]any{"leftFrontTirepressure": 240.0}, true}, {"rear tyre pressure", map[string]any{"rightRearTirepressure": 235.0}, true}, {"timestamp", map[string]any{"time": 1.7e12}, true}, {"range", map[string]any{"enduranceMileage": 391.0}, true}, {"zero range only", map[string]any{"enduranceMileage": 0.0}, false}, {"range as a string", map[string]any{"enduranceMileage": "391"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := RealtimeReady(tt.info); got != tt.want { t.Errorf("RealtimeReady(%v) = %v, want %v", tt.info, got, tt.want) } }) } } func TestVehicleName(t *testing.T) { tests := []struct { v Vehicle want string }{ {Vehicle{AutoAlias: "Daily", AutoPlate: "AB-123", ModelName: "Sealion 7"}, "Daily"}, {Vehicle{AutoPlate: "AB-123", ModelName: "Sealion 7"}, "AB-123"}, {Vehicle{ModelName: "Sealion 7"}, "Sealion 7"}, {Vehicle{VIN: "LC0"}, "LC0"}, } for _, tt := range tests { if got := tt.v.Name(); got != tt.want { t.Errorf("Name() = %q, want %q", got, tt.want) } } }