Server: rewrite in Go as an API-only service
Same endpoints, request formats, validation rules and InfluxDB layout as
the Next.js server, so the car needs no change: POST /v1/telemetry,
PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET
/v1/consumption/history/{id}, GET /v1/health. The dashboard, login and
the /v1/history reads are gone; Grafana takes over the dashboards.
- Go 1.26 standard library (net/http routing, log/slog) plus the
official InfluxDB 2 client. Fails closed at startup on a missing
InfluxDB setting or an ingest token under 24 characters.
- ABRP forwarder ported: newest fresh sample, rate limited, never
queued, credentials only in the form body.
- 22 tests: validation, aggregation, forwarding, config, and the
handlers through a fake store. govulncheck clean (x/net 0.60.0,
toolchain go1.26.9).
- Image: static binary on distroless, about 11 MB, UID 65532, port
8080; the build runs vet and the tests.
- Dev shell: Go, gopls and govulncheck instead of Node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testInstallation = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
testSession = "11111111-2222-4333-8444-555555555555"
|
||||
testNowS = 1_790_000_000
|
||||
)
|
||||
|
||||
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}`))
|
||||
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}`))
|
||||
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 TestTelemetryRejectsBadSamples(t *testing.T) {
|
||||
for name, sample := range map[string]string{
|
||||
"milliseconds": `{"utc":1790000000000}`,
|
||||
"fractional utc": `{"utc":1790000000.5}`,
|
||||
"soc above 100": `{"utc":1790000000,"soc":101}`,
|
||||
"string speed": `{"utc":1790000000,"speed":"fast"}`,
|
||||
"flag 2": `{"utc":1790000000,"is_parked":2}`,
|
||||
"half position": `{"utc":1790000000,"lat":52.1}`,
|
||||
"not an object": `5`,
|
||||
} {
|
||||
if _, err := validateTelemetryBatch(telemetryBody(sample)); !isValidationError(err) {
|
||||
t.Errorf("%s: want ValidationError, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) {
|
||||
if _, err := validateTelemetryBatch(telemetryBody("")); !isValidationError(err) {
|
||||
t.Error("empty batch accepted")
|
||||
}
|
||||
many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",")
|
||||
if _, err := validateTelemetryBatch(telemetryBody(many)); !isValidationError(err) {
|
||||
t.Error("501 samples accepted")
|
||||
}
|
||||
if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`)); !isValidationError(err) {
|
||||
t.Error("bad installation id accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user