Files
macker/server/geocode_test.go
2026-10-28 14:04:39 -05:00

130 lines
4.6 KiB
Go

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