Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
39a4f09700 fix: handle rating-count rendering fallback and add regressions
Some checks failed
ci / test (push) Has been cancelled
2026-02-14 05:11:52 +01:00
aligurelli
de52de72c1 feat: add userRatingCount to search, nearby, and details
Add the userRatingCount field from the Google Places API (New) to
PlaceSummary and PlaceDetails types. This allows consumers to see
the total number of user ratings for each place.

Changes:
- Add userRatingCount to search, nearby, and details field masks
- Add UserRatingCount field to placeItem payload struct
- Add UserRatingCount to PlaceSummary and PlaceDetails types
- Map the field in mapPlaceSummary and mapPlaceDetails
- Display rating count in CLI output as 'Rating: 4.5 (532)'
- Include as 'user_rating_count' in JSON output
2026-01-28 00:57:33 +03:00
8 changed files with 105 additions and 59 deletions

View File

@ -44,6 +44,7 @@ func TestSearchSuccess(t *testing.T) {
"formattedAddress": "123 Street",
"location": {"latitude": 1.23, "longitude": 4.56},
"rating": 4.7,
"userRatingCount": 532,
"priceLevel": "PRICE_LEVEL_MODERATE",
"types": ["cafe"],
"currentOpeningHours": {"openNow": true}
@ -95,6 +96,9 @@ func TestSearchSuccess(t *testing.T) {
if result.PriceLevel == nil || *result.PriceLevel != 2 {
t.Fatalf("unexpected price level: %#v", result.PriceLevel)
}
if result.UserRatingCount == nil || *result.UserRatingCount != 532 {
t.Fatalf("unexpected user rating count: %#v", result.UserRatingCount)
}
if result.OpenNow == nil || *result.OpenNow != true {
t.Fatalf("unexpected openNow: %#v", result.OpenNow)
}
@ -287,6 +291,7 @@ func TestNearbySearchSuccess(t *testing.T) {
"formattedAddress": "123 Street",
"location": {"latitude": 1.23, "longitude": 4.56},
"rating": 4.7,
"userRatingCount": 42,
"priceLevel": "PRICE_LEVEL_MODERATE",
"types": ["cafe"],
"currentOpeningHours": {"openNow": true}
@ -312,6 +317,9 @@ func TestNearbySearchSuccess(t *testing.T) {
if len(response.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(response.Results))
}
if response.Results[0].UserRatingCount == nil || *response.Results[0].UserRatingCount != 42 {
t.Fatalf("unexpected user rating count: %#v", response.Results[0].UserRatingCount)
}
if response.NextPageToken != "next" {
t.Fatalf("unexpected token: %s", response.NextPageToken)
}
@ -383,6 +391,7 @@ func TestDetailsSuccess(t *testing.T) {
"formattedAddress": "Central",
"location": {"latitude": 10, "longitude": 20},
"rating": 4.2,
"userRatingCount": 1234,
"priceLevel": "PRICE_LEVEL_FREE",
"types": ["park"],
"regularOpeningHours": {"weekdayDescriptions": ["Mon: 9-5"]},
@ -405,6 +414,9 @@ func TestDetailsSuccess(t *testing.T) {
if place.PlaceID != "place-123" {
t.Fatalf("unexpected id: %s", place.PlaceID)
}
if place.UserRatingCount == nil || *place.UserRatingCount != 1234 {
t.Fatalf("unexpected user rating count: %#v", place.UserRatingCount)
}
if place.OpenNow == nil || *place.OpenNow != false {
t.Fatalf("unexpected openNow")
}

View File

@ -9,7 +9,7 @@ import (
)
const (
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,priceLevel,types,regularOpeningHours,currentOpeningHours,nationalPhoneNumber,websiteUri"
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,userRatingCount,priceLevel,types,regularOpeningHours,currentOpeningHours,nationalPhoneNumber,websiteUri"
detailsFieldMaskReview = "reviews"
detailsFieldMaskPhotos = "photos"
)
@ -61,18 +61,19 @@ func detailsFieldMaskForRequest(req DetailsRequest) string {
func mapPlaceDetails(place placeItem) PlaceDetails {
return PlaceDetails{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
Phone: place.NationalPhoneNumber,
Website: place.WebsiteURI,
Hours: weekdayDescriptions(place.RegularOpeningHours),
OpenNow: openNow(place.CurrentOpeningHours),
Reviews: mapReviews(place.Reviews),
Photos: mapPhotos(place.Photos),
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
UserRatingCount: place.UserRatingCount,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
Phone: place.NationalPhoneNumber,
Website: place.WebsiteURI,
Hours: weekdayDescriptions(place.RegularOpeningHours),
OpenNow: openNow(place.CurrentOpeningHours),
Reviews: mapReviews(place.Reviews),
Photos: mapPhotos(place.Photos),
}
}

View File

@ -188,7 +188,7 @@ func autocompleteSubtitle(suggestion goplaces.AutocompleteSuggestion) string {
func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSummary) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)
writeRating(out, color, place.Rating, place.PriceLevel)
writeRating(out, color, place.Rating, place.UserRatingCount, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
}
@ -206,7 +206,7 @@ func writeAutocompleteSuggestion(out *bytes.Buffer, color Color, suggestion gopl
func writePlaceDetails(out *bytes.Buffer, color Color, place goplaces.PlaceDetails) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)
writeRating(out, color, place.Rating, place.PriceLevel)
writeRating(out, color, place.Rating, place.UserRatingCount, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
writeLine(out, color, "Phone", place.Phone)
@ -300,13 +300,19 @@ func writeLocation(out *bytes.Buffer, color Color, loc *goplaces.LatLng) {
writeLine(out, color, "Location", fmt.Sprintf("%.6f, %.6f", loc.Lat, loc.Lng))
}
func writeRating(out *bytes.Buffer, color Color, rating *float64, priceLevel *int) {
if rating == nil && priceLevel == nil {
func writeRating(out *bytes.Buffer, color Color, rating *float64, userRatingCount *int, priceLevel *int) {
if rating == nil && userRatingCount == nil && priceLevel == nil {
return
}
parts := make([]string, 0, 2)
parts := make([]string, 0, 3)
if rating != nil {
parts = append(parts, fmt.Sprintf("%.1f", *rating))
ratingStr := fmt.Sprintf("%.1f", *rating)
if userRatingCount != nil {
ratingStr += fmt.Sprintf(" (%d)", *userRatingCount)
}
parts = append(parts, ratingStr)
} else if userRatingCount != nil {
parts = append(parts, fmt.Sprintf("%d ratings", *userRatingCount))
}
if priceLevel != nil {
parts = append(parts, fmt.Sprintf("$%d", *priceLevel))

View File

@ -12,17 +12,19 @@ import (
func TestRenderSearch(t *testing.T) {
open := true
level := 2
ratingCount := 532
response := goplaces.SearchResponse{
Results: []goplaces.PlaceSummary{
{
PlaceID: "abc",
Name: "Cafe",
Address: "123 Street",
Location: &goplaces.LatLng{Lat: 1, Lng: 2},
Rating: floatPtr(4.5),
PriceLevel: &level,
Types: []string{"cafe", "coffee_shop"},
OpenNow: &open,
PlaceID: "abc",
Name: "Cafe",
Address: "123 Street",
Location: &goplaces.LatLng{Lat: 1, Lng: 2},
Rating: floatPtr(4.5),
UserRatingCount: &ratingCount,
PriceLevel: &level,
Types: []string{"cafe", "coffee_shop"},
OpenNow: &open,
},
},
NextPageToken: "next",
@ -35,6 +37,9 @@ func TestRenderSearch(t *testing.T) {
if !strings.Contains(output, "Rating") {
t.Fatalf("missing rating")
}
if !strings.Contains(output, "4.5 (532)") {
t.Fatalf("missing rating count")
}
if !strings.Contains(output, "Open now") {
t.Fatalf("missing open now")
}
@ -43,6 +48,24 @@ func TestRenderSearch(t *testing.T) {
}
}
func TestRenderSearchRatingCountOnly(t *testing.T) {
ratingCount := 12
response := goplaces.SearchResponse{
Results: []goplaces.PlaceSummary{
{
PlaceID: "abc",
Name: "Cafe",
UserRatingCount: &ratingCount,
},
},
}
output := renderSearch(NewColor(false), response)
if !strings.Contains(output, "12 ratings") {
t.Fatalf("missing rating count-only output: %s", output)
}
}
func TestRenderSearchEmpty(t *testing.T) {
output := renderSearch(NewColor(false), goplaces.SearchResponse{})
if !strings.Contains(output, "No results") {

View File

@ -8,7 +8,7 @@ import (
"strings"
)
const nearbyFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.types,places.currentOpeningHours"
const nearbyFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours"
// NearbySearch performs a nearby search around a location restriction.
func (c *Client) NearbySearch(ctx context.Context, req NearbySearchRequest) (NearbySearchResponse, error) {

View File

@ -11,6 +11,7 @@ type placeItem struct {
FormattedAddress string `json:"formattedAddress,omitempty"`
Location *location `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
UserRatingCount *int `json:"userRatingCount,omitempty"`
PriceLevel string `json:"priceLevel,omitempty"`
Types []string `json:"types,omitempty"`
CurrentOpeningHours *openingHours `json:"currentOpeningHours,omitempty"`

View File

@ -8,7 +8,7 @@ import (
"strings"
)
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.types,places.currentOpeningHours,nextPageToken"
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours,nextPageToken"
// Search performs a text search with optional filters.
func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
@ -100,14 +100,15 @@ func buildSearchBody(req SearchRequest) map[string]any {
func mapPlaceSummary(place placeItem) PlaceSummary {
return PlaceSummary{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
OpenNow: openNow(place.CurrentOpeningHours),
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
UserRatingCount: place.UserRatingCount,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
OpenNow: openNow(place.CurrentOpeningHours),
}
}

View File

@ -84,31 +84,33 @@ type NearbySearchResponse struct {
// PlaceSummary is a compact view of a place.
type PlaceSummary struct {
PlaceID string `json:"place_id"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Location *LatLng `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
PriceLevel *int `json:"price_level,omitempty"`
Types []string `json:"types,omitempty"`
OpenNow *bool `json:"open_now,omitempty"`
PlaceID string `json:"place_id"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Location *LatLng `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
UserRatingCount *int `json:"user_rating_count,omitempty"`
PriceLevel *int `json:"price_level,omitempty"`
Types []string `json:"types,omitempty"`
OpenNow *bool `json:"open_now,omitempty"`
}
// PlaceDetails is a detailed view of a place.
type PlaceDetails struct {
PlaceID string `json:"place_id"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Location *LatLng `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
PriceLevel *int `json:"price_level,omitempty"`
Types []string `json:"types,omitempty"`
Phone string `json:"phone,omitempty"`
Website string `json:"website,omitempty"`
Hours []string `json:"hours,omitempty"`
OpenNow *bool `json:"open_now,omitempty"`
Reviews []Review `json:"reviews,omitempty"`
Photos []Photo `json:"photos,omitempty"`
PlaceID string `json:"place_id"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Location *LatLng `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
UserRatingCount *int `json:"user_rating_count,omitempty"`
PriceLevel *int `json:"price_level,omitempty"`
Types []string `json:"types,omitempty"`
Phone string `json:"phone,omitempty"`
Website string `json:"website,omitempty"`
Hours []string `json:"hours,omitempty"`
OpenNow *bool `json:"open_now,omitempty"`
Reviews []Review `json:"reviews,omitempty"`
Photos []Photo `json:"photos,omitempty"`
}
// LocationResolveRequest resolves a text location into place candidates.