package main import ( "io" "net/http" "net/http/httptest" "net/url" "testing" "time" ) func chunk(date string, atMs int64, km, kwh float64, soh *float64) ConsumptionChunk { return ConsumptionChunk{ InstallationID: testInstallation, Date: date, RecordedAt: time.UnixMilli(atMs), Day: Totals{Km: km, Kwh: kwh, Hours: km / 50, SocDrop: kwh / 0.6}, SohPercent: soh, } } func ptr(f float64) *float64 { return &f } func TestAggregateDaysSumsPerDayNewestFirstWithRunningLifetime(t *testing.T) { days := aggregateDays([]ConsumptionChunk{ chunk("2026-10-16", 1000, 10, 2, nil), chunk("2026-10-17", 3000, 5, 1, nil), chunk("2026-10-16", 2000, 20, 3, nil), }) if len(days) != 2 || days[0].Date != "2026-10-17" || days[1].Date != "2026-10-16" { t.Fatalf("order: %+v", days) } if days[1].Day.Km != 30 || days[1].Day.Kwh != 5 || days[1].RecordedAt.UnixMilli() != 2000 { t.Fatalf("day sums: %+v", days[1]) } if days[1].Lifetime.Km != 30 || days[0].Lifetime.Km != 35 || days[0].Lifetime.Kwh != 6 { t.Fatalf("lifetime: %+v / %+v", days[0].Lifetime, days[1].Lifetime) } } func TestAggregateDaysNewestSohWins(t *testing.T) { days := aggregateDays([]ConsumptionChunk{ chunk("2026-10-16", 2000, 1, 1, ptr(96)), chunk("2026-10-16", 1000, 1, 1, ptr(98)), chunk("2026-10-17", 3000, 1, 1, nil), }) if *days[1].SohPercent != 96 { t.Fatalf("want 96, got %v", *days[1].SohPercent) } if days[0].SohPercent != nil { t.Fatal("a day without SoH got one") } } func TestTlmSendsFlagsAsZeroOrOne(t *testing.T) { got := tlm(TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 50}, Flags: map[string]bool{"is_charging": true, "is_parked": false}}) if got["utc"] != int64(testNowS) || got["soc"] != 50.0 || got["is_charging"] != 1 || got["is_parked"] != 0 { t.Fatalf("tlm %+v", got) } } func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) { f := &Forwarder{MinInterval: 5 * time.Second} now := time.Unix(testNowS, 0) got, ok := f.pick([]TelemetrySample{{UTC: testNowS - 10}, {UTC: testNowS - 2}, {UTC: testNowS - 5}}, now) if !ok || got.UTC != testNowS-2 { t.Fatalf("picked %+v %v", got, ok) } if _, ok := f.pick([]TelemetrySample{{UTC: testNowS}}, now.Add(time.Second)); ok { t.Fatal("forwarded again within the interval") } if _, ok := f.pick([]TelemetrySample{{UTC: testNowS - 3}}, now.Add(time.Minute)); ok { t.Fatal("forwarded an older sample than the last one") } // A sample from ten minutes ago describes a car that has moved on. fresh := &Forwarder{MinInterval: 5 * time.Second} if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok { t.Fatal("forwarded a stale sample") } } func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) { received := make(chan *http.Request, 1) var form url.Values srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) form, _ = url.ParseQuery(string(body)) received <- r })) defer srv.Close() f := &Forwarder{APIKey: "key-123", UserToken: "token-456", Endpoint: srv.URL + "/1/tlm/send"} if err := f.send(t.Context(), TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 80}}); err != nil { t.Fatal(err) } r := <-received if r.URL.RawQuery != "" { t.Fatalf("credentials leaked into the URL: %s", r.URL.RawQuery) } if form.Get("api_key") != "key-123" || form.Get("token") != "token-456" || form.Get("tlm") != `{"soc":80,"utc":1790000000}` { t.Fatalf("form %v", form) } } func TestLoadConfigFailsClosed(t *testing.T) { env := map[string]string{ "INFLUX_URL": "http://influxdb:8086", "INFLUX_TOKEN": "t", "INFLUX_ORG": "mg4", "INGEST_TOKEN": "0123456789abcdef01234567", } get := func(k string) string { return env[k] } c, err := loadConfig(get) if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" || c.abrpMinInterval != 5*time.Second { t.Fatalf("defaults: %+v %v", c, err) } env["INGEST_TOKEN"] = "short" if _, err := loadConfig(get); err == nil { t.Fatal("short ingest token accepted") } env["INGEST_TOKEN"] = "0123456789abcdef01234567" delete(env, "INFLUX_TOKEN") if _, err := loadConfig(get); err == nil { t.Fatal("missing InfluxDB token accepted") } }