fix: harden route command
This commit is contained in:
parent
d157022860
commit
6089c1af9a
29
README.md
29
README.md
@ -8,6 +8,7 @@ Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy fo
|
||||
- Autocomplete suggestions for places + queries (session tokens supported).
|
||||
- Nearby search around a location restriction.
|
||||
- Place photos in details + photo media URLs.
|
||||
- Route search along a driving path (Routes API).
|
||||
- Location bias (lat/lng/radius) and pagination tokens.
|
||||
- Place details: hours, phone, website, rating, price, types.
|
||||
- Optional reviews in details (`--reviews` / `IncludeReviews`).
|
||||
@ -31,6 +32,7 @@ export GOOGLE_PLACES_API_KEY="..."
|
||||
Optional overrides:
|
||||
|
||||
- `GOOGLE_PLACES_BASE_URL` (testing, proxying, or mock servers)
|
||||
- `GOOGLE_ROUTES_BASE_URL` (testing Routes API or proxying)
|
||||
|
||||
### Getting a Google Places API Key
|
||||
|
||||
@ -44,18 +46,22 @@ Optional overrides:
|
||||
- Search for "Places API (New)" — make sure it says **(New)**!
|
||||
- Click "Enable"
|
||||
|
||||
3. **Create an API Key**
|
||||
3. **Enable the Routes API (for `route`)**
|
||||
- Search for "Routes API"
|
||||
- Click "Enable"
|
||||
|
||||
4. **Create an API Key**
|
||||
- Go to [APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials)
|
||||
- Click "Create Credentials" → "API Key"
|
||||
- Copy the key
|
||||
|
||||
4. **Set the Environment Variable**
|
||||
5. **Set the Environment Variable**
|
||||
```bash
|
||||
export GOOGLE_PLACES_API_KEY="your-api-key-here"
|
||||
```
|
||||
Add to your `~/.zshrc` or `~/.bashrc` to persist.
|
||||
|
||||
5. **(Recommended) Restrict the Key**
|
||||
6. **(Recommended) Restrict the Key**
|
||||
- Click on the key in Credentials
|
||||
- Under "API restrictions", select "Restrict key" → "Places API (New)"
|
||||
- Set quota limits in [Quotas](https://console.cloud.google.com/apis/api/places.googleapis.com/quotas)
|
||||
@ -65,13 +71,14 @@ Optional overrides:
|
||||
## CLI
|
||||
|
||||
```text
|
||||
goplaces [--api-key=KEY] [--base-url=URL] [--timeout=10s] [--json] [--no-color] [--verbose]
|
||||
goplaces [--api-key=KEY] [--base-url=URL] [--routes-base-url=URL] [--timeout=10s] [--json] [--no-color] [--verbose]
|
||||
<command>
|
||||
|
||||
Commands:
|
||||
autocomplete Autocomplete places and queries.
|
||||
nearby Search nearby places by location.
|
||||
search Search places by text query.
|
||||
route Search places along a route.
|
||||
details Fetch place details by place ID.
|
||||
photo Fetch a photo URL by photo name.
|
||||
resolve Resolve a location string to candidate places.
|
||||
@ -102,6 +109,12 @@ Nearby search:
|
||||
goplaces nearby --lat 47.6062 --lng -122.3321 --radius-m 1500 --type cafe --limit 5
|
||||
```
|
||||
|
||||
Route search:
|
||||
|
||||
```bash
|
||||
goplaces route "coffee" --from "Seattle, WA" --to "Portland, OR" --max-waypoints 5
|
||||
```
|
||||
|
||||
Details (with reviews):
|
||||
|
||||
```bash
|
||||
@ -181,6 +194,13 @@ photo, err := client.PhotoMedia(ctx, goplaces.PhotoMediaRequest{
|
||||
Name: "places/PLACE_ID/photos/PHOTO_ID",
|
||||
MaxWidthPx: 1200,
|
||||
})
|
||||
|
||||
route, err := client.Route(ctx, goplaces.RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "Seattle, WA",
|
||||
To: "Portland, OR",
|
||||
MaxWaypoints: 5,
|
||||
})
|
||||
```
|
||||
|
||||
## Notes
|
||||
@ -189,6 +209,7 @@ photo, err := client.PhotoMedia(ctx, goplaces.PhotoMediaRequest{
|
||||
- Price levels map to Google enums: `0` (free) → `4` (very expensive).
|
||||
- Reviews are returned only when `IncludeReviews`/`--reviews` is set.
|
||||
- Photos are returned only when `IncludePhotos`/`--photos` is set.
|
||||
- Route search requires the Google Routes API to be enabled.
|
||||
- Field masks are defined alongside each request (e.g. `search.go`, `details.go`, `autocomplete.go`).
|
||||
- The Places API is billed and quota-limited; keep an eye on your Cloud Console quotas.
|
||||
|
||||
|
||||
27
client.go
27
client.go
@ -19,17 +19,19 @@ const DefaultBaseURL = "https://places.googleapis.com/v1"
|
||||
|
||||
// Client wraps access to the Google Places API.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
apiKey string
|
||||
baseURL string
|
||||
routesBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Options configures the Places client.
|
||||
type Options struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
APIKey string
|
||||
BaseURL string
|
||||
RoutesBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient builds a client with sane defaults.
|
||||
@ -38,6 +40,10 @@ func NewClient(opts Options) *Client {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
routesBaseURL := strings.TrimRight(opts.RoutesBaseURL, "/")
|
||||
if routesBaseURL == "" {
|
||||
routesBaseURL = defaultRoutesBaseURL
|
||||
}
|
||||
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
@ -49,9 +55,10 @@ func NewClient(opts Options) *Client {
|
||||
}
|
||||
|
||||
return &Client{
|
||||
apiKey: opts.APIKey,
|
||||
baseURL: baseURL,
|
||||
httpClient: client,
|
||||
apiKey: opts.APIKey,
|
||||
baseURL: baseURL,
|
||||
routesBaseURL: routesBaseURL,
|
||||
httpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
docs/route.md
Normal file
34
docs/route.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Route Search
|
||||
|
||||
Route search samples waypoints along a route and runs a text search at each waypoint.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
goplaces route "coffee" --from "Seattle, WA" --to "Portland, OR" --max-waypoints 5
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--mode` travel mode: DRIVE, WALK, BICYCLE, TWO_WHEELER, TRANSIT.
|
||||
- `--radius-m` search radius per waypoint.
|
||||
- `--limit` results per waypoint.
|
||||
|
||||
## Library
|
||||
|
||||
```go
|
||||
response, err := client.Route(ctx, goplaces.RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "Seattle, WA",
|
||||
To: "Portland, OR",
|
||||
Mode: "DRIVE",
|
||||
RadiusM: 1000,
|
||||
MaxWaypoints: 5,
|
||||
Limit: 5,
|
||||
})
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires the Google Routes API to be enabled.
|
||||
- Waypoints are sampled evenly along the route polyline.
|
||||
@ -12,9 +12,11 @@ import (
|
||||
"github.com/steipete/goplaces"
|
||||
)
|
||||
|
||||
const placesSearchPath = "/places:searchText"
|
||||
|
||||
func TestRunSearchJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/places:searchText" {
|
||||
if r.URL.Path != placesSearchPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"places": [{"id": "abc"}]}`))
|
||||
@ -232,6 +234,78 @@ func TestRunNearbyHuman(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRouteJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/directions/v2:computeRoutes":
|
||||
_, _ = w.Write([]byte("{\"routes\":[{\"polyline\":{\"encodedPolyline\":\"_p~iF~ps|U_ulLnnqC_mqNvxq`@\"}}]}"))
|
||||
case placesSearchPath:
|
||||
_, _ = w.Write([]byte(`{"places":[{"id":"abc","displayName":{"text":"Cafe"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"route",
|
||||
"coffee",
|
||||
"--from", "A",
|
||||
"--to", "B",
|
||||
"--api-key", "test-key",
|
||||
"--base-url", server.URL,
|
||||
"--routes-base-url", server.URL,
|
||||
"--json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"waypoints\"") {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRouteHuman(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/directions/v2:computeRoutes":
|
||||
_, _ = w.Write([]byte("{\"routes\":[{\"polyline\":{\"encodedPolyline\":\"_p~iF~ps|U_ulLnnqC_mqNvxq`@\"}}]}"))
|
||||
case placesSearchPath:
|
||||
_, _ = w.Write([]byte(`{"places":[{"id":"abc","displayName":{"text":"Cafe"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"route",
|
||||
"coffee",
|
||||
"--from", "A",
|
||||
"--to", "B",
|
||||
"--api-key", "test-key",
|
||||
"--base-url", server.URL,
|
||||
"--routes-base-url", server.URL,
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Route waypoints") {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("unexpected stderr: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetailsJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/places/place-1" {
|
||||
@ -399,7 +473,7 @@ func TestRunPhotoHuman(t *testing.T) {
|
||||
|
||||
func TestRunResolveHuman(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/places:searchText" {
|
||||
if r.URL.Path != placesSearchPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"places": [{"id": "loc-1", "displayName": {"text": "Downtown"}}]}`))
|
||||
|
||||
@ -103,6 +103,34 @@ func TestRenderNearby(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRoute(t *testing.T) {
|
||||
response := goplaces.RouteResponse{
|
||||
Waypoints: []goplaces.RouteWaypoint{
|
||||
{
|
||||
Location: goplaces.LatLng{Lat: 1, Lng: 2},
|
||||
Results: []goplaces.PlaceSummary{{PlaceID: "place-1", Name: "Cafe"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
output := renderRoute(NewColor(false), response)
|
||||
if !strings.Contains(output, "Route waypoints") {
|
||||
t.Fatalf("missing route header")
|
||||
}
|
||||
if !strings.Contains(output, "Waypoint 1") {
|
||||
t.Fatalf("missing waypoint label")
|
||||
}
|
||||
if !strings.Contains(output, "Cafe") {
|
||||
t.Fatalf("missing place name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteEmpty(t *testing.T) {
|
||||
output := renderRoute(NewColor(false), goplaces.RouteResponse{})
|
||||
if !strings.Contains(output, "No results") {
|
||||
t.Fatalf("unexpected output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTitleFallback(t *testing.T) {
|
||||
title := formatTitle(NewColor(false), "", "")
|
||||
if !strings.Contains(title, "(no name)") {
|
||||
|
||||
@ -9,22 +9,23 @@ type Root struct {
|
||||
Global GlobalOptions `embed:""`
|
||||
Autocomplete AutocompleteCmd `cmd:"" help:"Autocomplete places and queries."`
|
||||
Nearby NearbyCmd `cmd:"" help:"Search nearby places by location."`
|
||||
Search SearchCmd `cmd:"" help:"Search places by text query."`
|
||||
Route RouteCmd `cmd:"" help:"Search places along a route."`
|
||||
Details DetailsCmd `cmd:"" help:"Fetch place details by place ID."`
|
||||
Photo PhotoCmd `cmd:"" help:"Fetch a photo URL by photo name."`
|
||||
Resolve ResolveCmd `cmd:"" help:"Resolve a location string to candidate places."`
|
||||
Search SearchCmd `cmd:"" help:"Search places by text query."`
|
||||
Route RouteCmd `cmd:"" help:"Search places along a route."`
|
||||
Details DetailsCmd `cmd:"" help:"Fetch place details by place ID."`
|
||||
Photo PhotoCmd `cmd:"" help:"Fetch a photo URL by photo name."`
|
||||
Resolve ResolveCmd `cmd:"" help:"Resolve a location string to candidate places."`
|
||||
}
|
||||
|
||||
// GlobalOptions are flags shared by all commands.
|
||||
type GlobalOptions struct {
|
||||
APIKey string `help:"Google Places API key." env:"GOOGLE_PLACES_API_KEY"`
|
||||
BaseURL string `help:"Places API base URL." env:"GOOGLE_PLACES_BASE_URL" default:"https://places.googleapis.com/v1"`
|
||||
Timeout time.Duration `help:"HTTP timeout." default:"10s"`
|
||||
JSON bool `help:"Output JSON."`
|
||||
NoColor bool `help:"Disable color output."`
|
||||
Verbose bool `help:"Verbose logging."`
|
||||
Version VersionFlag `name:"version" help:"Print version and exit."`
|
||||
APIKey string `help:"Google Places API key." env:"GOOGLE_PLACES_API_KEY"`
|
||||
BaseURL string `help:"Places API base URL." env:"GOOGLE_PLACES_BASE_URL" default:"https://places.googleapis.com/v1"`
|
||||
RoutesBaseURL string `help:"Routes API base URL." env:"GOOGLE_ROUTES_BASE_URL" default:"https://routes.googleapis.com"`
|
||||
Timeout time.Duration `help:"HTTP timeout." default:"10s"`
|
||||
JSON bool `help:"Output JSON."`
|
||||
NoColor bool `help:"Disable color output."`
|
||||
Verbose bool `help:"Verbose logging."`
|
||||
Version VersionFlag `name:"version" help:"Print version and exit."`
|
||||
}
|
||||
|
||||
// SearchCmd runs text search queries.
|
||||
|
||||
@ -69,9 +69,10 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
client := goplaces.NewClient(goplaces.Options{
|
||||
APIKey: root.Global.APIKey,
|
||||
BaseURL: root.Global.BaseURL,
|
||||
Timeout: root.Global.Timeout,
|
||||
APIKey: root.Global.APIKey,
|
||||
BaseURL: root.Global.BaseURL,
|
||||
RoutesBaseURL: root.Global.RoutesBaseURL,
|
||||
Timeout: root.Global.Timeout,
|
||||
})
|
||||
|
||||
app := &App{
|
||||
|
||||
12
route.go
12
route.go
@ -12,8 +12,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
routesEndpoint = "https://routes.googleapis.com/directions/v2:computeRoutes"
|
||||
routesFieldMask = "routes.polyline.encodedPolyline"
|
||||
defaultRoutesBaseURL = "https://routes.googleapis.com"
|
||||
routesPath = "/directions/v2:computeRoutes"
|
||||
routesFieldMask = "routes.polyline.encodedPolyline"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -165,8 +166,8 @@ func (c *Client) computeRoutePolyline(ctx context.Context, req RouteRequest) (st
|
||||
"destination": map[string]any{
|
||||
"address": req.To,
|
||||
},
|
||||
"travelMode": req.Mode,
|
||||
"polylineQuality": "OVERVIEW",
|
||||
"travelMode": req.Mode,
|
||||
"polylineQuality": "OVERVIEW",
|
||||
"polylineEncoding": "ENCODED_POLYLINE",
|
||||
}
|
||||
if req.Language != "" {
|
||||
@ -176,7 +177,8 @@ func (c *Client) computeRoutePolyline(ctx context.Context, req RouteRequest) (st
|
||||
body["regionCode"] = req.Region
|
||||
}
|
||||
|
||||
payload, err := c.doRequest(ctx, http.MethodPost, routesEndpoint, body, routesFieldMask)
|
||||
endpoint := c.routesBaseURL + routesPath
|
||||
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, routesFieldMask)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
316
route_test.go
Normal file
316
route_test.go
Normal file
@ -0,0 +1,316 @@
|
||||
package goplaces
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeRoutePolyline(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != routesPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != routesFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte("{\"routes\": [{\"polyline\": {\"encodedPolyline\": \"_p~iF~ps|U_ulLnnqC_mqNvxq`@\"}}]}"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", RoutesBaseURL: server.URL})
|
||||
polyline, err := client.computeRoutePolyline(context.Background(), RouteRequest{
|
||||
From: "Seattle",
|
||||
To: "Portland",
|
||||
Mode: travelModeDrive,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("computeRoutePolyline error: %v", err)
|
||||
}
|
||||
if polyline == "" {
|
||||
t.Fatalf("expected polyline")
|
||||
}
|
||||
if gotBody["travelMode"] != travelModeDrive {
|
||||
t.Fatalf("unexpected travelMode: %#v", gotBody["travelMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePolyline(t *testing.T) {
|
||||
points, err := decodePolyline("_p~iF~ps|U_ulLnnqC_mqNvxq`@")
|
||||
if err != nil {
|
||||
t.Fatalf("decodePolyline error: %v", err)
|
||||
}
|
||||
if len(points) != 3 {
|
||||
t.Fatalf("expected 3 points, got %d", len(points))
|
||||
}
|
||||
if points[0].Lat != 38.5 || points[0].Lng != -120.2 {
|
||||
t.Fatalf("unexpected first point: %#v", points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePolylineInvalid(t *testing.T) {
|
||||
_, err := decodePolyline("")
|
||||
if err == nil {
|
||||
t.Fatalf("expected decode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePolylineMalformed(t *testing.T) {
|
||||
_, err := decodePolyline("abc")
|
||||
if err == nil {
|
||||
t.Fatalf("expected malformed error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleWaypoints(t *testing.T) {
|
||||
points := []LatLng{{Lat: 0, Lng: 0}, {Lat: 0, Lng: 1}, {Lat: 0, Lng: 2}}
|
||||
waypoints := sampleWaypoints(points, 2)
|
||||
if len(waypoints) != 2 {
|
||||
t.Fatalf("expected 2 waypoints, got %d", len(waypoints))
|
||||
}
|
||||
if waypoints[0].Lng != 0 || waypoints[1].Lng != 2 {
|
||||
t.Fatalf("unexpected waypoints: %#v", waypoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleWaypointsSingle(t *testing.T) {
|
||||
points := []LatLng{{Lat: 1, Lng: 1}, {Lat: 2, Lng: 2}}
|
||||
waypoints := sampleWaypoints(points, 1)
|
||||
if len(waypoints) != 1 {
|
||||
t.Fatalf("expected 1 waypoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleWaypointsSinglePoint(t *testing.T) {
|
||||
points := []LatLng{{Lat: 1, Lng: 1}}
|
||||
waypoints := sampleWaypoints(points, 5)
|
||||
if len(waypoints) != 1 {
|
||||
t.Fatalf("expected 1 waypoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointAtDistanceBounds(t *testing.T) {
|
||||
points := []LatLng{{Lat: 0, Lng: 0}, {Lat: 0, Lng: 2}}
|
||||
cumulative := cumulativeDistances(points)
|
||||
if got := pointAtCumulative(points, cumulative, -1); got != points[0] {
|
||||
t.Fatalf("expected first point, got %#v", got)
|
||||
}
|
||||
if got := pointAtCumulative(points, cumulative, cumulative[len(cumulative)-1]+1); got != points[1] {
|
||||
t.Fatalf("expected last point, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueWaypoints(t *testing.T) {
|
||||
points := []LatLng{{Lat: 1, Lng: 1}, {Lat: 1, Lng: 1}, {Lat: 2, Lng: 2}}
|
||||
unique := uniqueWaypoints(points)
|
||||
if len(unique) != 2 {
|
||||
t.Fatalf("expected 2 unique points, got %d", len(unique))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistanceMeters(t *testing.T) {
|
||||
distance := distanceMeters(LatLng{Lat: 0, Lng: 0}, LatLng{Lat: 0, Lng: 1})
|
||||
if distance <= 0 {
|
||||
t.Fatalf("expected positive distance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalDistanceEmpty(t *testing.T) {
|
||||
if totalDistance([]LatLng{{Lat: 1, Lng: 1}}) != 0 {
|
||||
t.Fatalf("expected zero distance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointAtDistanceEmpty(t *testing.T) {
|
||||
point := pointAtDistance(nil, 10)
|
||||
if point != (LatLng{}) {
|
||||
t.Fatalf("expected empty point")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRouteRequest(t *testing.T) {
|
||||
err := validateRouteRequest(RouteRequest{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
err = validateRouteRequest(RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "A",
|
||||
To: "B",
|
||||
Mode: "FLY",
|
||||
Limit: 1,
|
||||
RadiusM: 1,
|
||||
MaxWaypoints: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected mode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRouteRequestBounds(t *testing.T) {
|
||||
err := validateRouteRequest(RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "A",
|
||||
To: "B",
|
||||
Mode: travelModeDrive,
|
||||
Limit: 0,
|
||||
RadiusM: -1,
|
||||
MaxWaypoints: 999,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected bounds error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRouteDefaults(t *testing.T) {
|
||||
req := applyRouteDefaults(RouteRequest{
|
||||
Query: " coffee ",
|
||||
From: " A ",
|
||||
To: " B ",
|
||||
Mode: "walk",
|
||||
})
|
||||
if req.Mode != travelModeWalk {
|
||||
t.Fatalf("unexpected mode: %s", req.Mode)
|
||||
}
|
||||
if req.Limit != defaultRouteLimit {
|
||||
t.Fatalf("unexpected limit: %d", req.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRouteDefaultsEmpty(t *testing.T) {
|
||||
req := applyRouteDefaults(RouteRequest{})
|
||||
if req.Mode != travelModeDrive {
|
||||
t.Fatalf("expected default mode")
|
||||
}
|
||||
if req.Limit != defaultRouteLimit {
|
||||
t.Fatalf("expected default limit")
|
||||
}
|
||||
if req.RadiusM != defaultRouteRadiusM {
|
||||
t.Fatalf("expected default radius")
|
||||
}
|
||||
if req.MaxWaypoints != defaultRouteWaypoints {
|
||||
t.Fatalf("expected default waypoints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeRoutePolylineErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"routes":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", RoutesBaseURL: server.URL})
|
||||
_, err := client.computeRoutePolyline(context.Background(), RouteRequest{From: "A", To: "B"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected route error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeRoutePolylineEmpty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"routes":[{"polyline":{"encodedPolyline":""}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", RoutesBaseURL: server.URL})
|
||||
_, err := client.computeRoutePolyline(context.Background(), RouteRequest{From: "A", To: "B"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected empty polyline error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeRoutePolylineInvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", RoutesBaseURL: server.URL})
|
||||
_, err := client.computeRoutePolyline(context.Background(), RouteRequest{From: "A", To: "B"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected json error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEndToEnd(t *testing.T) {
|
||||
searchCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case routesPath:
|
||||
_, _ = w.Write([]byte("{\"routes\": [{\"polyline\": {\"encodedPolyline\": \"_p~iF~ps|U_ulLnnqC_mqNvxq`@\"}}]}"))
|
||||
case "/places:searchText":
|
||||
searchCalls++
|
||||
_, _ = w.Write([]byte(`{"places":[{"id":"abc","displayName":{"text":"Cafe"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL, RoutesBaseURL: server.URL})
|
||||
response, err := client.Route(context.Background(), RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "Seattle",
|
||||
To: "Portland",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("route error: %v", err)
|
||||
}
|
||||
if len(response.Waypoints) == 0 {
|
||||
t.Fatalf("expected waypoints")
|
||||
}
|
||||
if searchCalls == 0 {
|
||||
t.Fatalf("expected search calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteSearchError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case routesPath:
|
||||
_, _ = w.Write([]byte("{\"routes\": [{\"polyline\": {\"encodedPolyline\": \"_p~iF~ps|U_ulLnnqC_mqNvxq`@\"}}]}"))
|
||||
case "/places:searchText":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad"))
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL, RoutesBaseURL: server.URL})
|
||||
_, err := client.Route(context.Background(), RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "Seattle",
|
||||
To: "Portland",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected route error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteComputeRouteError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != routesPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"routes":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", RoutesBaseURL: server.URL})
|
||||
_, err := client.Route(context.Background(), RouteRequest{
|
||||
Query: "coffee",
|
||||
From: "A",
|
||||
To: "B",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected route error")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user