package main import ( "errors" "strings" "testing" "time" ) const ( testInstallation = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b" testSession = "11111111-2222-4333-8444-555555555555" testNowS = 1_790_000_000 ) // testNow is the sample time the telemetry tests send, as "now" for the clock-ahead check. var testNow = time.Unix(testNowS, 0) func isValidationError(err error) bool { var v *ValidationError return errors.As(err, &v) } func TestConsumptionAcceptsAWellFormedChunk(t *testing.T) { c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07", "recordedAt":1760000000000,"day":{"km":42.5,"kwh":7.25,"hours":0.9,"socDrop":11.5}, "lifetime":{"km":12000,"kwh":2100,"hours":240,"socDrop":3400},"sohPercent":97.5}`)) if err != nil { t.Fatal(err) } if c.Date != "2026-10-07" || c.Day.Kwh != 7.25 || c.RecordedAt.UnixMilli() != 1760000000000 || *c.SohPercent != 97.5 { t.Fatalf("unexpected chunk %+v", c) } } func TestConsumptionMissingSohIsOmittedNotDefaulted(t *testing.T) { // The car omits a field it could not read. Writing 0 would be a battery at nothing. c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07", "recordedAt":1,"day":{"km":1,"kwh":1,"hours":1,"socDrop":1}}`)) if err != nil { t.Fatal(err) } if c.SohPercent != nil { t.Fatalf("SoH should be absent, got %v", *c.SohPercent) } } func TestConsumptionRejectsBadInput(t *testing.T) { base := map[string]string{ "installationId": `"` + testInstallation + `"`, "date": `"2026-10-07"`, "recordedAt": "1", "day": `{"km":1,"kwh":1,"hours":1,"socDrop":1}`, } cases := map[string][2]string{ "bad id": {"installationId", `"nope"`}, "german date": {"date", `"07.10.2026"`}, "negative timestamp": {"recordedAt", "-5"}, "fractional time": {"recordedAt", "1.5"}, "string number": {"day", `{"km":"1","kwh":1,"hours":1,"socDrop":1}`}, "huge totals": {"day", `{"km":1e9,"kwh":1,"hours":1,"socDrop":1}`}, "missing field": {"day", `{"km":1,"kwh":1,"hours":1}`}, "bad lifetime": {"lifetime", `{"km":1}`}, "soh above 100": {"sohPercent", "101"}, } for name, change := range cases { fields := map[string]string{} for k, v := range base { fields[k] = v } fields[change[0]] = change[1] var parts []string for k, v := range fields { parts = append(parts, `"`+k+`":`+v) } if _, err := validateConsumption([]byte("{" + strings.Join(parts, ",") + "}")); !isValidationError(err) { t.Errorf("%s: want ValidationError, got %v", name, err) } } for _, body := range []string{"", "[]", "null", "{", `{"a":1}{"b":2}`} { if _, err := validateConsumption([]byte(body)); !isValidationError(err) { t.Errorf("body %q: want ValidationError, got %v", body, err) } } } func chargingBody(overrides string) []byte { return []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `", "startedAt":1760500000000,"endedAt":1760501800000,"durationSeconds":1800,"energyKwh":30, "pricePerKwh":0.59,"startSocPercent":20,"endSocPercent":80, "curve":[{"timestampMs":1760500060000,"powerKw":95,"socPercent":21},{"timestampMs":1760500120000,"powerKw":110}]` + overrides + `}`) } func TestChargingSessionAppliesTheServersLossAndCost(t *testing.T) { // The car's own grid and cost figures are not trusted; the server applies 10 % loss. s, err := validateChargingSession(chargingBody(`,"gridEnergyKwh":1,"totalCost":1`), testSession) if err != nil { t.Fatal(err) } if s.GridEnergyKwh != 33 || s.TotalCost != 33*0.59 || s.ChargingLossPercent != 10 { t.Fatalf("grid %v cost %v loss %v", s.GridEnergyKwh, s.TotalCost, s.ChargingLossPercent) } if len(s.Curve) != 2 || s.Curve[1].SocPercent != nil || *s.Curve[0].SocPercent != 21 { t.Fatalf("curve not kept as sent: %+v", s.Curve) } } func TestChargingSessionMustMatchItsPath(t *testing.T) { if _, err := validateChargingSession(chargingBody(""), "22222222-2222-4333-8444-555555555555"); !isValidationError(err) { t.Fatalf("want ValidationError, got %v", err) } } func TestChargingSessionRejectsOversizedCurvesAndBadPoints(t *testing.T) { points := strings.Repeat(`{"timestampMs":1,"powerKw":1},`, 721) long := []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `","curve":[` + strings.TrimSuffix(points, ",") + `]}`) if _, err := validateChargingSession(long, testSession); !isValidationError(err) { t.Errorf("721 points: want ValidationError, got %v", err) } bad := []byte(strings.Replace(string(chargingBody("")), `"powerKw":95`, `"powerKw":-5`, 1)) if _, err := validateChargingSession(bad, testSession); !isValidationError(err) { t.Errorf("negative power: want ValidationError, got %v", err) } } func telemetryBody(samples string) []byte { return []byte(`{"installationId":"` + testInstallation + `","samples":[` + samples + `]}`) } func TestTelemetryKeepsOnlyWhatWasSent(t *testing.T) { b, err := validateTelemetryBatch(telemetryBody( `{"utc":1790000000,"soc":81,"power":-12.5,"speed":0,"lat":52.1,"lon":7.6,"is_charging":1,"is_dcfc":false}`), testNow) if err != nil { t.Fatal(err) } s := b.Samples[0] if s.UTC != testNowS || s.Numbers["soc"] != 81 || s.Numbers["power"] != -12.5 || s.Numbers["lat"] != 52.1 { t.Fatalf("numbers %+v", s.Numbers) } if v, ok := s.Numbers["speed"]; !ok || v != 0 { t.Fatal("a real 0 must be kept") } if s.Flags["is_charging"] != true || s.Flags["is_dcfc"] != false || len(s.Flags) != 2 { t.Fatalf("flags %+v", s.Flags) } // An unread signal stays absent: a missing SOC must never become 0. if _, ok := s.Numbers["est_battery_range"]; ok { t.Fatal("absent field invented") } } func TestTelemetryDropsUnknownFields(t *testing.T) { b, err := validateTelemetryBatch(telemetryBody(`{"utc":1790000000,"future_field":3}`), testNow) if err != nil { t.Fatal(err) } if len(b.Samples[0].Numbers) != 0 || len(b.Samples[0].Flags) != 0 { t.Fatalf("unknown field kept: %+v", b.Samples[0]) } } func TestTelemetryDropsBadValuesAndKeepsTheRest(t *testing.T) { for name, c := range map[string]struct { sample string dropped string kept bool // the sample itself survives }{ "milliseconds": {`{"utc":1790000000000,"soc":5}`, "samples[0].utc", false}, "fractional utc": {`{"utc":1790000000.5,"soc":5}`, "samples[0].utc", false}, "clock unset": {`{"utc":0,"soc":5}`, "samples[0].utc", false}, "clock a day ahead": {`{"utc":1790086400,"soc":5}`, "samples[0].utc", false}, "not an object": {`5`, "samples[0]", false}, "soc above 100": {`{"utc":1790000000,"soc":101,"speed":20}`, "samples[0].soc", true}, "reversing": {`{"utc":1790000000,"soc":5,"speed":-4}`, "samples[0].speed", true}, "string speed": {`{"utc":1790000000,"soc":5,"speed":"fast"}`, "samples[0].speed", true}, "flag 2": {`{"utc":1790000000,"soc":5,"is_parked":2}`, "samples[0].is_parked", true}, "half position": {`{"utc":1790000000,"soc":5,"lat":52.1}`, "samples[0].lat/lon", true}, } { b, err := validateTelemetryBatch(telemetryBody(c.sample), testNow) if err != nil { t.Errorf("%s: the batch was rejected: %v", name, err) continue } if len(b.Dropped) != 1 || b.Dropped[0] != c.dropped { t.Errorf("%s: dropped %v, want %s", name, b.Dropped, c.dropped) } if (len(b.Samples) == 1) != c.kept { t.Errorf("%s: %d samples kept", name, len(b.Samples)) } if c.kept && b.Samples[0].Numbers["soc"] != 5 && name != "soc above 100" { t.Errorf("%s: the valid soc was lost", name) } } } func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) { if _, err := validateTelemetryBatch(telemetryBody(""), testNow); !isValidationError(err) { t.Error("empty batch accepted") } many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",") if _, err := validateTelemetryBatch(telemetryBody(many), testNow); !isValidationError(err) { t.Error("501 samples accepted") } if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) { t.Error("bad installation id accepted") } }