Files
tajniak81andClaude Opus 5 fd75833707 The cabin's temperature, and the one it is heading for
The climate cards landed with the endpoint migration, but only as two more
folded dumps of key/value pairs. What a driver opens that tab for in January is
one number, and it was three taps down inside a card called Climate.

So currentTemperature and targetTemperature join the headline readings, beside
the pair of electric ranges and for the same stated reason: neither figure
answers the question on its own. A cabin at 12° means nothing until you know it
is climbing towards 21°, and the gap between them is how long to leave the
scraper in the boot. Being derived from headlineMetricSpecs, both are arrangeable
the moment they exist — a car's saved order of readings can name them without
anything else being told they are there, and a test now says so rather than
leaving it to be noticed when a PATCH starts rejecting a key.

The unit is fixed at Celsius, because Toyota Connected is the European service
and there is no imperial reading to convert from. That is a default and not a
claim: a payload that names its own unit is still believed over it, the way
every other reading here works, so a service that one day reports Fahrenheit is
labelled Fahrenheit rather than relabelled into a wrong Celsius.

The two apps needed the two labels in three languages each and nothing else.
That is the shape working: a section is an id the app localizes and a reading is
a key it localizes, so a card added on the server arrives in both clients
already folded, already arrangeable, already translated. The one thing the Web
App did need was a corrected comment — the note explaining why cards fold still
said Toyota reports eight sections, and it is the argument for folding them, so
it should count the ten there now are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:45:58 +02:00

710 lines
24 KiB
Go

package api
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb"
"drivervault/apiserver/internal/plugins"
)
// The vehicle-provider layer deliberately searches payloads by key name instead
// of by path, so these tests pin the behaviour that makes that safe: top-level
// keys win over nested ones, the three numeric shapes are all understood, and an
// unrecognized value is dropped rather than guessed at.
func decode(t *testing.T, s string) any {
t.Helper()
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
t.Fatalf("bad test JSON: %v", err)
}
return v
}
func TestFindNodePrefersShallowKeys(t *testing.T) {
// "model" appears at the top level and again inside a nested dealer record.
// The shallow one has to win, or an import picks up the dealer's data.
tree := decode(t, `{
"model": "Yaris",
"dealer": {"model": "Corolla", "name": "City Toyota"}
}`)
if got := findString(tree, "model"); got != "Yaris" {
t.Fatalf("model = %q, want Yaris", got)
}
}
func TestFindNodeDescendsWhenAbsentAtTop(t *testing.T) {
tree := decode(t, `{"payload": {"vehicle": {"vin": "VIN123"}}}`)
if got := findString(tree, "vin"); got != "VIN123" {
t.Fatalf("vin = %q, want VIN123", got)
}
}
func TestFindNodeMissingKey(t *testing.T) {
tree := decode(t, `{"a": 1}`)
if got := findString(tree, "vin"); got != "" {
t.Fatalf("expected empty string for a missing key, got %q", got)
}
if _, _, ok := findMeasure(tree, "odometer"); ok {
t.Error("expected findMeasure to report not-found")
}
}
func TestFindMeasureShapes(t *testing.T) {
cases := []struct {
name string
json string
value float64
unit string
}{
{"bare number", `{"fuelLevel": 62}`, 62, ""},
{"numeric string", `{"fuelLevel": "62"}`, 62, ""},
{"value+unit object", `{"odometer": {"value": 270185, "unit": "km"}}`, 270185, "km"},
{"snake case key", `{"fuel_level": 40}`, 40, ""},
{"screaming key", `{"FUEL-LEVEL": 40}`, 40, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
n, unit, ok := findMeasure(decode(t, tc.json), "fuelLevel", "odometer")
if !ok {
t.Fatal("expected a measure")
}
if n != tc.value {
t.Errorf("value = %v, want %v", n, tc.value)
}
if unit != tc.unit {
t.Errorf("unit = %q, want %q", unit, tc.unit)
}
})
}
}
func TestOdometerKmConvertsMiles(t *testing.T) {
km, ok := odometerKm(decode(t, `{"odometer": {"value": 100, "unit": "mi"}}`))
if !ok {
t.Fatal("expected an odometer reading")
}
if km != 161 {
t.Fatalf("km = %d, want 161 (100 mi)", km)
}
km, ok = odometerKm(decode(t, `{"odometer": {"value": 270185, "unit": "km"}}`))
if !ok || km != 270185 {
t.Fatalf("km = %d (ok=%v), want 270185", km, ok)
}
// A zero or absent odometer is "no reading", not a reading of zero — it must
// never overwrite a real stored value with 0.
if _, ok := odometerKm(decode(t, `{"odometer": {"value": 0, "unit": "km"}}`)); ok {
t.Error("a zero odometer should not count as a reading")
}
}
func TestFlattenJSON(t *testing.T) {
fields, truncated := flattenJSON(decode(t, `{
"payload": {
"odometer": {"value": 270185, "unit": "km"},
"nothing": null,
"blank": " ",
"flag": true,
"warnings": ["oil", "tyre"]
}
}`), 100)
if truncated {
t.Error("should not be truncated")
}
got := map[string]string{}
for _, f := range fields {
got[f.Key] = f.Value
}
want := map[string]string{
"payload.odometer.value": "270185",
"payload.odometer.unit": "km",
"payload.flag": "true",
"payload.warnings[0]": "oil",
"payload.warnings[1]": "tyre",
}
for k, v := range want {
if got[k] != v {
t.Errorf("field %q = %q, want %q", k, got[k], v)
}
}
// Nulls and whitespace-only strings are omitted: they are the provider having
// no value, and rendering them as rows would be noise.
for _, k := range []string{"payload.nothing", "payload.blank"} {
if _, present := got[k]; present {
t.Errorf("field %q should have been omitted", k)
}
}
}
func TestFlattenJSONTruncates(t *testing.T) {
fields, truncated := flattenJSON(decode(t, `{"a":1,"b":2,"c":3,"d":4}`), 2)
if !truncated {
t.Error("expected truncated=true")
}
if len(fields) != 2 {
t.Fatalf("len(fields) = %d, want 2", len(fields))
}
}
func TestHeadlineMetrics(t *testing.T) {
trees := []any{
decode(t, `{"payload": {"odometer": {"value": 270185, "unit": "km"}, "fuelLevel": 62}}`),
decode(t, `{"payload": {"batteryLevel": 80, "chargingStatus": "charging",
"evRange": {"value": 412, "unit": "km"}, "evRangeWithAc": {"value": 389, "unit": "km"}}}`),
decode(t, `{"payload": {"vehicleLocation": {"latitude": 52.2297, "longitude": 21.0122}}}`),
}
got := map[string]providerMetric{}
for _, m := range headlineMetrics(trees) {
got[m.Key] = m
}
if got["odometer"].Value != "270185" || got["odometer"].Unit != "km" {
t.Errorf("odometer = %+v", got["odometer"])
}
if got["fuelLevel"].Value != "62" || got["fuelLevel"].Unit != "%" {
t.Errorf("fuelLevel = %+v", got["fuelLevel"])
}
if got["batteryLevel"].Value != "80" {
t.Errorf("batteryLevel = %+v", got["batteryLevel"])
}
if got["chargingStatus"].Value != "charging" {
t.Errorf("chargingStatus = %+v", got["chargingStatus"])
}
// Range with the climate control on is its own reading, so a payload that
// reports both shows both rather than one standing in for the other.
if got["evRange"].Value != "412" || got["evRange"].Unit != "km" {
t.Errorf("evRange = %+v", got["evRange"])
}
if got["evRangeWithAc"].Value != "389" || got["evRangeWithAc"].Unit != "km" {
t.Errorf("evRangeWithAc = %+v", got["evRangeWithAc"])
}
if got["location"].Value != "52.22970, 21.01220" {
t.Errorf("location = %+v", got["location"])
}
}
// The climate section contributes two readings, and the pair is the point: a
// cabin at 12° heading for 21° is a car still warming up, which neither figure
// says on its own.
func TestHeadlineMetricsClimateTemperatures(t *testing.T) {
trees := []any{
decode(t, `{"payload": {"status": "on", "currentTemperature": 12.5,
"targetTemperature": 21, "duration": 10}}`),
}
got := map[string]providerMetric{}
for _, m := range headlineMetrics(trees) {
got[m.Key] = m
}
if got["cabinTemperature"].Value != "12.5" || got["cabinTemperature"].Unit != "°C" {
t.Errorf("cabinTemperature = %+v, want 12.5 °C", got["cabinTemperature"])
}
if got["targetTemperature"].Value != "21" || got["targetTemperature"].Unit != "°C" {
t.Errorf("targetTemperature = %+v, want 21 °C", got["targetTemperature"])
}
// A payload that names its own unit is believed over the fixed default, so a
// service reporting Fahrenheit is not relabelled into a wrong Celsius.
trees = []any{decode(t, `{"payload": {"currentTemperature": {"value": 68, "unit": "°F"}}}`)}
got = map[string]providerMetric{}
for _, m := range headlineMetrics(trees) {
got[m.Key] = m
}
if got["cabinTemperature"].Value != "68" || got["cabinTemperature"].Unit != "°F" {
t.Errorf("cabinTemperature = %+v, want 68 °F", got["cabinTemperature"])
}
}
// Every headline reading must be nameable in a car's saved arrangement, or the
// tab would show a reading the user cannot move.
func TestArrangeableCarMetricsCoverHeadlines(t *testing.T) {
for _, spec := range headlineMetricSpecs {
if !arrangeableCarMetrics[spec.key] {
t.Errorf("reading %q is shown but cannot be arranged", spec.key)
}
}
}
// A reading converted out of miles must not claim to know the range to the
// metre. 62 mi is 99.779136 km exactly; the tab shows 99.8, the way the same
// figure reported in km already would read.
func TestHeadlineMetricsRoundsConvertedReadings(t *testing.T) {
trees := []any{
decode(t, `{"payload": {"odometer": {"value": 12000, "unit": "mi"},
"evRange": {"value": 62, "unit": "mi"},
"evRangeWithAc": {"value": 99.744, "unit": "km"},
"batteryLevel": 23.4}}`),
}
got := map[string]providerMetric{}
for _, m := range headlineMetrics(trees) {
got[m.Key] = m
}
if got["evRange"].Value != "99.8" || got["evRange"].Unit != "km" {
t.Errorf("evRange = %+v, want 99.8 km", got["evRange"])
}
// Rounding is by the reading's kind, not by whether it was converted: a
// provider reporting km with too many decimals gets the same treatment.
if got["evRangeWithAc"].Value != "99.7" {
t.Errorf("evRangeWithAc = %+v, want 99.7", got["evRangeWithAc"])
}
// A whole number stays whole rather than gaining a ".0".
if got["odometer"].Value != "19312.1" {
t.Errorf("odometer = %+v, want 19312.1", got["odometer"])
}
// A battery percentage is a whole number; tenths of a percent are noise.
if got["batteryLevel"].Value != "23" || got["batteryLevel"].Unit != "%" {
t.Errorf("batteryLevel = %+v, want 23 %%", got["batteryLevel"])
}
}
func TestNormalizeProviderFuelType(t *testing.T) {
cases := map[string]string{
"HV": "hybrid",
"hybrid": "hybrid",
"PETROL_HYBRID": "hybrid", // hybrid wins over the petrol substring
"phev": "hybrid",
"Diesel": "diesel",
"BEV": "electric",
"Battery Electric": "electric",
"gasoline": "petrol",
"FCEV": "hydrogen",
"": "",
"something else": "",
}
for in, want := range cases {
if got := normalizeProviderFuelType(in); got != want {
t.Errorf("normalizeProviderFuelType(%q) = %q, want %q", in, got, want)
}
}
}
func TestISODateOnly(t *testing.T) {
cases := map[string]string{
"2015-06-01": "2015-06-01",
"2015-06-01T00:00:00Z": "2015-06-01",
// Ambiguous formats are dropped rather than guessed: 01/06/2015 is June
// or January depending on who wrote it.
"01/06/2015": "",
"2015-06": "",
"": "",
"not a date": "",
}
for in, want := range cases {
if got := isoDateOnly(in); got != want {
t.Errorf("isoDateOnly(%q) = %q, want %q", in, got, want)
}
}
}
func TestToyotaVehiclesNormalizes(t *testing.T) {
raw := json.RawMessage(`{"payload": [{
"vin": "JTDKB20U000000001",
"alias": "Daily driver",
"brand": "T",
"modelName": "Yaris",
"modelYear": 2015,
"licensePlate": "ABC 1234",
"fuelType": "HV",
"firstRegistrationDate": "2015-06-01T00:00:00Z",
"imageUrl": "https://example.invalid/yaris.png"
}]}`)
got := toyotaSource{}.vehicles(raw)
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
v := got[0]
if v.ID != "JTDKB20U000000001" || v.VIN != v.ID {
t.Errorf("id/vin = %q/%q", v.ID, v.VIN)
}
if v.Name != "Daily driver" {
t.Errorf("name = %q, want the owner's alias", v.Name)
}
if v.Make != "Toyota" || v.Model != "Yaris" || v.Year != 2015 {
t.Errorf("make/model/year = %q/%q/%d", v.Make, v.Model, v.Year)
}
if v.Registration != "ABC 1234" {
t.Errorf("registration = %q", v.Registration)
}
if v.FuelType != "hybrid" {
t.Errorf("fuelType = %q, want hybrid", v.FuelType)
}
if v.FirstRegistrationDate != "2015-06-01" {
t.Errorf("firstRegistrationDate = %q", v.FirstRegistrationDate)
}
if len(v.Fields) == 0 || len(v.Raw) == 0 {
t.Error("the whole upstream object should still reach the UI via Fields/Raw")
}
}
func TestToyotaVehiclesFallbacks(t *testing.T) {
// No alias and no VIN: the name falls back to make + model and the id to
// whatever other identifier the payload carries.
got := toyotaSource{}.vehicles(json.RawMessage(`[{"guid": "abc", "brand": "L", "modelName": "IS 300h"}]`))
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].ID != "abc" {
t.Errorf("id = %q, want abc", got[0].ID)
}
if got[0].Name != "Lexus IS 300h" {
t.Errorf("name = %q, want Lexus IS 300h", got[0].Name)
}
// A vehicle with nothing to address it by is skipped, not imported blind.
anonymous := toyotaSource{}.vehicles(json.RawMessage(`{"payload": [{"colour": "blue"}]}`))
if len(anonymous) != 0 {
t.Errorf("expected an unidentifiable vehicle to be skipped, got %d", len(anonymous))
}
malformed := toyotaSource{}.vehicles(json.RawMessage(`not json`))
if len(malformed) != 0 {
t.Errorf("expected no vehicles from malformed JSON, got %d", len(malformed))
}
}
func TestImportSelectionDefaultsToEverything(t *testing.T) {
// The plain request {"vehicleId": …} must mean "fetch everything you can".
all := (*importSelection)(nil).resolve()
if !all.identity || !all.fuelType || !all.dates || !all.odometer {
t.Fatalf("nil selection = %+v, want everything on", all)
}
no := false
partial := (&importSelection{Odometer: &no}).resolve()
if partial.odometer {
t.Error("odometer should be off")
}
if !partial.identity || !partial.fuelType || !partial.dates {
t.Errorf("unspecified groups should stay on, got %+v", partial)
}
}
func TestApplyVehicleToCarSkipsBlanks(t *testing.T) {
car := carRecord{Make: "Toyota", Model: "Yaris", Registration: "OLD 111"}.toModel()
// The provider reports a model but no registration: the stored plate must
// survive rather than being blanked by an absent upstream field.
applyVehicleToCar(&car, providerVehicle{Model: "Yaris Hybrid"}, resolvedSelection{identity: true})
if car.Model != "Yaris Hybrid" {
t.Errorf("model = %q, want the provider value", car.Model)
}
if car.Registration != "OLD 111" {
t.Errorf("registration = %q, want the stored value kept", car.Registration)
}
// A group that was not selected is not written at all.
car2 := carRecord{FuelType: "petrol"}.toModel()
applyVehicleToCar(&car2, providerVehicle{FuelType: "hybrid"}, resolvedSelection{identity: true})
if car2.FuelType != "petrol" {
t.Errorf("fuelType = %q, want petrol (group not selected)", car2.FuelType)
}
}
func TestFindVehicleMatchesIDOrVIN(t *testing.T) {
vehicles := []providerVehicle{{ID: "abc", VIN: "VIN1"}, {ID: "VIN2", VIN: "VIN2"}}
if v, ok := findVehicle(vehicles, "vin1"); !ok || v.ID != "abc" {
t.Error("expected a case-insensitive VIN match")
}
if v, ok := findVehicle(vehicles, "VIN2"); !ok || v.ID != "VIN2" {
t.Error("expected an id match")
}
if _, ok := findVehicle(vehicles, ""); ok {
t.Error("an empty id must not match the first vehicle")
}
if _, ok := findVehicle(vehicles, "nope"); ok {
t.Error("unexpected match")
}
}
func TestVehicleSourceRegistry(t *testing.T) {
src, ok := vehicleSourceByID("toyota")
if !ok {
t.Fatal("toyota should be a registered vehicle source")
}
if src.label() != "MyToyota" {
t.Errorf("label = %q, want MyToyota", src.label())
}
if len(src.sections()) == 0 {
t.Error("expected per-vehicle sections")
}
if _, ok := vehicleSourceByID("ford"); ok {
t.Error("unregistered provider should not resolve")
}
}
// ---- routing + gating, through the real Handler ------------------------------
//
// These go through the whole middleware chain against a stand-in PocketBase, with
// the Toyota plugin left disabled. That covers two things a unit test of the
// helpers cannot: that every route is actually registered under the pattern the
// clients call, and that a closed gate is reported the way each kind of endpoint
// needs — a listing answers 200 with a reason so the UI can say "connect this in
// Settings", while a write answers 4xx so a caller can never read the reply as a
// successful import.
//
// Note an unauthenticated probe proves neither: withAuth wraps the mux, so every
// path — registered or not — answers 401 before routing happens. Hence the bearer.
const provCarID = "car-1"
// fakeProviderPB answers the identity, user-record and car-record calls these
// endpoints make, and records the last car PATCH so a test can assert what was
// written. Nothing else is needed: the caller owns the car, so the access check
// never consults car_shares.
type fakeProviderPB struct {
carProvider, carVehicleID string
mu sync.Mutex
lastCarPatch map[string]any
}
func (f *fakeProviderPB) patch() map[string]any {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastCarPatch
}
func (f *fakeProviderPB) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"token": "svc-token"})
})
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
writeJSON(w, 401, map[string]any{})
return
}
writeJSON(w, 200, map[string]any{"record": map[string]any{
"id": e2eUserID, "email": e2eUserMail, "name": "Owner", "role": "user", "organization": "",
}})
})
mux.HandleFunc("GET /api/collections/users/records/"+e2eUserID, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"id": e2eUserID, "email": e2eUserMail, "role": "user"})
})
mux.HandleFunc("GET /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, f.carRecord())
})
mux.HandleFunc("PATCH /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) {
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
f.mu.Lock()
f.lastCarPatch = in
// Apply the provider columns so a follow-up read reflects the write.
if v, ok := in["provider"].(string); ok {
f.carProvider = v
}
if v, ok := in["provider_vehicle_id"].(string); ok {
f.carVehicleID = v
}
f.mu.Unlock()
writeJSON(w, 200, f.carRecord())
})
return mux
}
func (f *fakeProviderPB) carRecord() map[string]any {
f.mu.Lock()
defer f.mu.Unlock()
return map[string]any{
"id": provCarID, "name": "Yaris", "owner": e2eUserID,
"provider": f.carProvider, "provider_vehicle_id": f.carVehicleID,
}
}
// newProviderTestServer wires a Server against the fake PocketBase with the
// Toyota plugin present but disabled — the "administrator turned it off" state,
// which closes the gate without any network call to Toyota.
func newProviderTestServer(t *testing.T, fake *fakeProviderPB) *httptest.Server {
t.Helper()
pbSrv := httptest.NewServer(fake.handler())
t.Cleanup(pbSrv.Close)
s := New(config.Config{UsersCollection: "users"},
pb.New(pbSrv.URL, "admin@test.local", "pw"))
// The global layer normally lives in PocketBase; seed it in memory so this
// test does not have to stand up an app_settings collection too.
s.pluginStore = plugins.NewMemoryStore([]byte(`{"toyota":{"enabled":false}}`))
s.plugins = plugins.NewManager(s.pluginStore)
if err := s.plugins.Load(context.Background()); err != nil {
t.Fatalf("load plugins: %v", err)
}
srv := httptest.NewServer(s.Handler())
t.Cleanup(srv.Close)
return srv
}
// call makes an authenticated request and returns the status and decoded body.
func call(t *testing.T, srv *httptest.Server, method, path, body string) (int, map[string]any) {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+e2eBearer)
req.Header.Set("Content-Type", "application/json")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
out := map[string]any{}
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func TestVehicleProviderListing(t *testing.T) {
srv := newProviderTestServer(t, &fakeProviderPB{})
status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
list, _ := body["providers"].([]any)
if len(list) == 0 {
t.Fatal("expected at least the Toyota provider")
}
first, _ := list[0].(map[string]any)
if first["id"] != "toyota" || first["label"] != "MyToyota" {
t.Errorf("provider = %+v, want toyota/MyToyota", first)
}
// Disabled globally: reported as not connected, with a reason to show the user
// rather than an error.
if first["connected"] != false {
t.Errorf("connected = %v, want false", first["connected"])
}
if first["detail"] == "" {
t.Error("a disconnected provider must explain why")
}
}
func TestVehicleProviderGateSoftOnListHardOnWrite(t *testing.T) {
srv := newProviderTestServer(t, &fakeProviderPB{})
// Listing: 200 + unavailable, so the UI degrades to "connect in Settings".
status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers/toyota/vehicles", "")
if status != http.StatusOK {
t.Errorf("vehicles status = %d, want 200", status)
}
if body["unavailable"] != true || body["detail"] == "" {
t.Errorf("vehicles body = %+v, want unavailable with a reason", body)
}
// Import: 400, so a client can never mistake the reply for a created car.
status, body = call(t, srv, http.MethodPost, "/api/vehicle-providers/toyota/import", `{"vehicleId":"VIN1"}`)
if status != http.StatusBadRequest {
t.Errorf("import status = %d, want 400", status)
}
if body["error"] == "" {
t.Error("a refused import must say why")
}
if _, created := body["car"]; created {
t.Error("a refused import must not return a car")
}
}
func TestVehicleProviderUnknownProvider(t *testing.T) {
srv := newProviderTestServer(t, &fakeProviderPB{})
if status, _ := call(t, srv, http.MethodGet, "/api/vehicle-providers/ford/vehicles", ""); status != http.StatusNotFound {
t.Errorf("status = %d, want 404 for an unregistered provider", status)
}
}
func TestCarProviderRoutesOnUnlinkedCar(t *testing.T) {
srv := newProviderTestServer(t, &fakeProviderPB{}) // car has no provider link
// Reaching these messages proves the routes are registered and the car's
// access check ran — a missing route would 404 from the mux with no message.
status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "")
if status != http.StatusNotFound {
t.Errorf("snapshot status = %d, want 404", status)
}
if !strings.Contains(toStr(body["error"]), "not linked") {
t.Errorf("snapshot error = %v, want the not-linked message", body["error"])
}
status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider/sync", `{}`)
if status != http.StatusBadRequest {
t.Errorf("sync status = %d, want 400", status)
}
if !strings.Contains(toStr(body["error"]), "not linked") {
t.Errorf("sync error = %v, want the not-linked message", body["error"])
}
// Linking needs the vehicle to be on the caller's account, so a closed gate
// refuses it outright rather than storing a link that could never be read.
status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider",
`{"provider":"toyota","vehicleId":"VIN1"}`)
if status != http.StatusBadRequest {
t.Errorf("link status = %d, want 400 while the provider is unusable", status)
}
}
func TestCarProviderUnlinkWorksWithProviderUnreachable(t *testing.T) {
// Unlinking must not depend on the provider being reachable — otherwise a car
// could stay stuck to an account the user has since removed. It writes only
// the two link columns, leaving the rest of the car alone.
fake := &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"}
srv := newProviderTestServer(t, fake)
status, body := call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider", `{"provider":"","vehicleId":""}`)
if status != http.StatusOK {
t.Fatalf("unlink status = %d (%v), want 200", status, body["error"])
}
if body["provider"] != nil {
t.Errorf("returned car still reports provider %v", body["provider"])
}
patch := fake.patch()
if patch["provider"] != "" || patch["provider_vehicle_id"] != "" {
t.Errorf("patch = %+v, want both link columns cleared", patch)
}
if len(patch) != 2 {
t.Errorf("patch touched %d columns (%+v); unlinking must write only the link", len(patch), patch)
}
}
func TestCarProviderSnapshotGateClosed(t *testing.T) {
// A linked car whose provider the caller cannot currently use: the snapshot is
// still a 200 with a reason, so the tab renders an explanation instead of an
// error page.
srv := newProviderTestServer(t, &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"})
status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if body["provider"] != "toyota" || body["label"] != "MyToyota" {
t.Errorf("body = %+v, want the provider identified", body)
}
if body["unavailable"] != true || body["detail"] == "" {
t.Errorf("body = %+v, want unavailable with a reason", body)
}
if _, fetched := body["sections"]; fetched {
t.Error("no sections should be fetched when the gate is closed")
}
}
func TestFormatNumber(t *testing.T) {
cases := map[float64]string{270185: "270185", 62.5: "62.5", 0: "0", -3: "-3"}
for in, want := range cases {
if got := formatNumber(in); got != want {
t.Errorf("formatNumber(%v) = %q, want %q", in, got, want)
}
}
}