package main import ( "context" "io" "math" "net/http" "net/http/httptest" "strings" "testing" "time" "macker/datapoint" ) type transportFunc func(*http.Request) (*http.Response, error) func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func testGeocoder(t *testing.T, response string, status int) (*geocoder, *int) { t.Helper() g, err := newGeocoder("https://example.com/reverse", "secret") if err != nil { t.Fatal(err) } calls := 0 g.client = &http.Client{Transport: transportFunc(func(r *http.Request) (*http.Response, error) { calls++ if r.Header.Get("Authorization") != "Bearer secret" || r.URL.Query().Get("api_key") != "" { t.Error("key must stay in server-side Authorization header") } if r.URL.Query().Get("format") != "jsonv2" || r.URL.Query().Get("lat") == "" || r.URL.Query().Get("lon") == "" { t.Error("missing reverse geocoding parameters") } return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil })} return g, &calls } func TestGeocodeCacheDistanceAndRateLimit(t *testing.T) { g, calls := testGeocoder(t, `{"lat":"41.8001","lon":"-87.6","display_name":"123 Main Street"}`, 200) now := time.Now().UTC() g.update(context.Background(), 41.8, -87.6, now) v := g.view(41.8, -87.6, now) if v.Status != "ready" || v.Address == nil || v.Address.Label != "123 Main Street" || math.Abs(v.Address.DistanceMeters-11.12) > 0.1 { t.Fatalf("bad address: %+v", v) } // A nearby fix reuses the cached address but computes its own distance. g.update(context.Background(), 41.80005, -87.6, now.Add(time.Minute)) moved := g.view(41.80005, -87.6, now.Add(time.Minute)) if *calls != 1 || moved.Address.DistanceMeters >= v.Address.DistanceMeters { t.Fatal("cache not reused or distance not updated") } // A distant fix must not display an address from the previous area. if g.view(42, -87.6, now).Status != "pending" { t.Fatal("old address shown for distant fix") } g.update(context.Background(), 42, -87.6, now.Add(time.Second)) if *calls != 1 { t.Fatal("rate limit bypassed") } g.update(context.Background(), 42, -87.6, now.Add(31*time.Second)) if *calls != 2 { t.Fatal("moved fix did not trigger new lookup") } if g.view(41.8, -87.6, now.Add(6*time.Minute)).Address != nil { t.Fatal("expired cache used") } } func TestGeocodeFailureAndRetry(t *testing.T) { for name, response := range map[string]string{"no match": `{"error":"Unable to geocode"}`, "invalid coords": `{"lat":"NaN","lon":"0","display_name":"bad"}`, "malformed": "not JSON", "oversized": strings.Repeat("x", (1<<20)+1)} { t.Run(name, func(t *testing.T) { g, calls := testGeocoder(t, response, 200) now := time.Now() g.update(context.Background(), 1, 2, now) if g.view(1, 2, now).Status != "unavailable" { t.Fatal("bad response accepted") } g.update(context.Background(), 1, 2, now.Add(time.Second)) if *calls != 1 { t.Fatal("failure retried too soon") } g.update(context.Background(), 1, 2, now.Add(31*time.Second)) if *calls != 2 { t.Fatal("failure not retried") } }) } g, _ := testGeocoder(t, `{}`, 429) g.update(context.Background(), 1, 2, time.Now()) if g.view(1, 2, time.Now()).Status != "unavailable" { t.Fatal("HTTP error accepted") } } func TestGeocoderConfigurationAndAPI(t *testing.T) { for _, endpoint := range []string{"file:///tmp/address", "https://nominatim.openstreetmap.org/reverse", "https://user:pass@example.com"} { if _, err := newGeocoder(endpoint, ""); err == nil { t.Fatalf("accepted endpoint %q", endpoint) } } disabled, err := newGeocoder("", "") if err != nil || disabled != nil || disabled.view(0, 0, time.Now()).Status != "disabled" { t.Fatal("bad disabled behavior") } g, _ := testGeocoder(t, `{"lat":"41.8","lon":"-87.6","display_name":"123 Main Street"}`, 200) now := time.Now() g.update(context.Background(), 41.8, -87.6, now) tr := &tracker{geocoder: g} tr.accept([]datapoint.DataPoint{{Lat: 41.8, Long: -87.6}}, now) rr := httptest.NewRecorder() tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil)) if !strings.Contains(rr.Body.String(), `"label":"123 Main Street"`) || strings.Contains(rr.Body.String(), "secret") { t.Fatalf("bad API response: %s", rr.Body.String()) } } func TestDistanceMeters(t *testing.T) { if distanceMeters(0, 0, 0, 0) != 0 { t.Fatal("identical coordinates have a distance") } if got := distanceMeters(0, 0, 0, 1); math.Abs(got-111195) > 1 { t.Fatalf("unexpected one degree distance: %f", got) } if got := distanceMeters(0, 179.999, 0, -179.999); math.Abs(got-222.39) > 1 { t.Fatalf("bad antimeridian distance: %f", got) } }