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"]) } } 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) } } }