Files
mg4/server/api_test.go
T
nilsandClaude Opus 5.5 ca2f899403 App 2.11.0, server 2.3.0: live data straight to ABRP; settings regrouped
ABRP keeps getting live data while the car server is down: with an ABRP
API key and user token set in the app (Live data tab, or abrp_api_key /
abrp_token in the config file), the car sends the newest sample to ABRP
itself at every upload. It tells the server the newest sample ABRP took
(abrpSentUtc), and the server forwards only newer ones, so ABRP gets
each sample once. Without credentials in the app the server forwards
everything, as before. The credentials sit in encrypted storage, go in
the request body, and neither they nor ABRP's answer are logged.

Settings are grouped by what they are for:
- Live data (was Upload): on/off, ABRP credentials, upload frequency.
- Settings (replaces the Wi-Fi tab): full screen and voice key,
  usable capacity and manual SoH (were on Consumption), charging price
  (was on Charging), Wi-Fi.
Charging and Live data get icons of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:15:21 +02:00

227 lines
7.6 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type fakeStore struct {
mu sync.Mutex
telemetry []TelemetrySample
chunks []ConsumptionChunk
sessions []ChargingSession
devices []Device
fail error
}
func (f *fakeStore) WriteTelemetry(_ context.Context, _ string, s []TelemetrySample) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
f.telemetry = append(f.telemetry, s...)
return nil
}
func (f *fakeStore) WriteConsumptionChunk(_ context.Context, c ConsumptionChunk) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
f.chunks = append(f.chunks, c)
return nil
}
func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) error {
f.mu.Lock()
defer f.mu.Unlock()
f.sessions = append(f.sessions, s)
return f.fail
}
func (f *fakeStore) SaveDevice(_ context.Context, d Device) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
f.devices = append(f.devices, d)
return nil
}
func (f *fakeStore) LoadDevices(context.Context) ([]Device, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.devices, f.fail
}
func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.chunks, f.fail
}
const (
testToken = "ingest-test-token-0123456789abcdef"
testApprovalPassword = "approval-password-123"
)
func newTestAPI() (*API, *fakeStore) {
store := &fakeStore{}
return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{},
Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store
}
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestHealthNeedsNoToken(t *testing.T) {
api, _ := newTestAPI()
rec := do(t, api.Routes(), "GET", "/v1/health", "", "")
if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"status":"ok"}` {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
}
func TestEveryOtherEndpointNeedsTheIngestToken(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
for _, c := range []struct{ method, path string }{
{"POST", "/v1/telemetry"},
{"PUT", "/v1/consumption/daily"},
{"PUT", "/v1/charging-sessions/" + testSession},
{"GET", "/v1/consumption/history/" + testInstallation},
} {
for _, token := range []string{"", "wrong-token-0123456789abcdef", testToken + "x"} {
if rec := do(t, h, c.method, c.path, token, "{}"); rec.Code != 401 {
t.Errorf("%s %s with %q: want 401, got %d", c.method, c.path, token, rec.Code)
}
}
}
}
func TestTelemetryIsStoredAndBadValuesAreDropped(t *testing.T) {
api, store := newTestAPI()
h := api.Routes()
rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
if rec.Code != 200 || len(store.telemetry) != 1 || !strings.Contains(rec.Body.String(), `"stored":1`) {
t.Fatalf("%d %s, stored %d", rec.Code, rec.Body, len(store.telemetry))
}
// One bad value costs that value, not the batch: the car drops what the server rejects.
rec = do(t, h, "POST", "/v1/telemetry", testToken,
string(telemetryBody(`{"utc":1790000000,"soc":120,"speed":-3},{"utc":1790000015,"soc":79}`)))
if rec.Code != 200 || len(store.telemetry) != 3 || !strings.Contains(rec.Body.String(), "samples[0].soc") ||
!strings.Contains(rec.Body.String(), "samples[0].speed") || !strings.Contains(rec.Body.String(), `"stored":2`) {
t.Fatalf("%d %s, stored %d", rec.Code, rec.Body, len(store.telemetry))
}
if _, ok := store.telemetry[1].Numbers["soc"]; ok {
t.Fatal("out-of-range soc stored")
}
// Nothing usable is still a 200: sending it again would not help.
rec = do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":5}`)))
if rec.Code != 200 || len(store.telemetry) != 3 || !strings.Contains(rec.Body.String(), `"stored":0`) {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
// A malformed request is still a 400.
if rec := do(t, h, "POST", "/v1/telemetry", testToken, `{"installationId":"nope","samples":[]}`); rec.Code != 400 {
t.Fatalf("malformed: %d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/telemetry", testToken, ""); rec.Code != 405 {
t.Fatalf("GET on a POST route: want 405, got %d", rec.Code)
}
}
func TestAFailedWriteIsA500WithoutDetail(t *testing.T) {
api, store := newTestAPI()
store.fail = errors.New("influx says: token xyz rejected")
rec := do(t, api.Routes(), "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
if rec.Code != 500 || strings.Contains(rec.Body.String(), "xyz") {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
}
func TestOversizedBodiesAreRejected(t *testing.T) {
api, _ := newTestAPI()
big := `{"x":"` + strings.Repeat("a", maxBodyBytes) + `"}`
if rec := do(t, api.Routes(), "PUT", "/v1/consumption/daily", testToken, big); rec.Code != 400 {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestHistoryReturnsDaysNewestFirstAndTheLifetime(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
day := func(date string, at int64) string {
return `{"installationId":"` + testInstallation + `","date":"` + date + `","recordedAt":` +
jsonInt(at) + `,"day":{"km":2.5,"kwh":0.4,"hours":0.03,"socDrop":0.6}}`
}
// Two chunks on one day and one on the next. Retry dedupe is InfluxDB's overwrite and is
// covered by the end-to-end check, not this fake.
for _, body := range []string{day("2026-10-16", 1000), day("2026-10-16", 2000), day("2026-10-17", 3000)} {
if rec := do(t, h, "PUT", "/v1/consumption/daily", testToken, body); rec.Code != 204 {
t.Fatalf("chunk: %d %s", rec.Code, rec.Body)
}
}
rec := do(t, h, "GET", "/v1/consumption/history/"+testInstallation, testToken, "")
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
var got struct {
Records []struct {
Date string `json:"date"`
Day Totals `json:"day"`
} `json:"records"`
Lifetime Totals `json:"lifetime"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got.Records) != 2 || got.Records[0].Date != "2026-10-17" || got.Records[1].Day.Km != 5 || got.Lifetime.Km != 7.5 {
t.Fatalf("%s", rec.Body)
}
if rec := do(t, h, "GET", "/v1/consumption/history/not-a-uuid", testToken, ""); rec.Code != 400 {
t.Fatalf("bad id: want 400, got %d", rec.Code)
}
}
func jsonInt(v int64) string {
b, _ := json.Marshal(v)
return string(b)
}
func TestSamplesTheCarSentToAbrpAreNotForwardedAgain(t *testing.T) {
samples := []TelemetrySample{{UTC: 100}, {UTC: 115}, {UTC: 130}}
if got := notYetAtAbrp(samples, 0); len(got) != 3 {
t.Fatalf("without abrpSentUtc everything is forwardable: %d", len(got))
}
if got := notYetAtAbrp(samples, 130); len(got) != 0 {
t.Fatalf("the car sent the newest: %d left", len(got))
}
if got := notYetAtAbrp(samples, 115); len(got) != 1 || got[0].UTC != 130 {
t.Fatalf("only the newer sample: %+v", got)
}
b, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":1790000000,"samples":[{"utc":1790000000}]}`), testNow)
if err != nil || b.AbrpSentUTC != 1790000000 {
t.Fatalf("abrpSentUtc not read: %v %d", err, b.AbrpSentUTC)
}
if _, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":"x","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) {
t.Fatal("a malformed abrpSentUtc was accepted")
}
}