MyToyota reports two range figures for an EV, and only one of them was reaching the readings: evRangeWithAc was listed as a fallback alias for evRange, so on a car that reports both — every bZ4X — the first key won and the second was never shown. It is its own reading now. The gap between the two is the useful part: it is what running the A/C costs you. Both are labelled for the pair, "Electric range (A/C off)" beside "Electric range (A/C on)", so neither figure is left ambiguous now that they sit next to each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
635 lines
21 KiB
Go
635 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"drivervault/apiserver/internal/config"
|
|
"drivervault/apiserver/internal/pb"
|
|
)
|
|
|
|
// 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)
|
|
|
|
pluginsFile := filepath.Join(t.TempDir(), "plugins.json")
|
|
if err := os.WriteFile(pluginsFile, []byte(`{"toyota":{"enabled":false}}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := New(config.Config{UsersCollection: "users", PluginsFile: pluginsFile},
|
|
pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
|
if err := s.plugins.Load(); 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)
|
|
}
|
|
}
|
|
}
|