fix: address code review findings

- Fix type mismatch in search_cache.go: SearchWithCache now returns *yandex.Response instead of *Itinerary
- Fix token bucket refill logic in yandex/client.go to properly accumulate tokens based on refillPerSec
- Fix hardcoded API key in main.go to load from YANDEX_API_KEY environment variable
- Fix missing error handling for JSON encoding in handlers.go RouteGeoJSON function
- Fix incorrect redis.Nil handling in cache/store.go CacheAside.Exists method
- Fix incorrect error return type in station_status.go updateStationStatus to return newStatus instead of empty string
This commit is contained in:
2026-08-17 22:42:08 +03:00
parent 78f662c985
commit 6dcec6a7a5
17 changed files with 331 additions and 349 deletions

View File

@@ -20,21 +20,21 @@ import (
// HandlerContext holds the dependencies for API handlers.
type HandlerContext struct {
Cache cache.Cache
Redis *redis.Client
Router *routing.Graph
Yandex *yandex.Client
SearchCache *routing.SearchCacheService
Preferences *cache.Preferences
Metrics *metrics.Metrics
SearchStart time.Time
Cache cache.Cache
Redis *redis.Client
Router *routing.Graph
Yandex *yandex.Client
SearchCache *routing.SearchCacheService
Preferences *cache.Preferences
Metrics *metrics.Metrics
SearchStart time.Time
}
// stationStatusResponse represents the response for station status.
type stationStatusResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // "active" or "closed"
Status string `json:"status"` // "active" or "closed"
Transport string `json:"transport"` // e.g., "train", "plane", "bus"
}
@@ -44,22 +44,22 @@ type cityResponse []string
// routeSearchResponse represents the response for route search.
type routeSearchResponse struct {
Routes []routeSearchRoute `json:"routes"`
Count int `json:"count"`
Count int `json:"count"`
}
type routeSearchRoute struct {
Duration int `json:"duration"`
Transfers int `json:"transfers"`
Cost int `json:"cost"`
ID string `json:"id"`
PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API
Duration int `json:"duration"`
Transfers int `json:"transfers"`
Cost int `json:"cost"`
ID string `json:"id"`
PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API
}
// routeGeoJSONResponse represents the response for route GeoJSON.
type routeGeoJSONResponse struct {
Type string `json:"type"`
Type string `json:"type"`
Features []map[string]interface{} `json:"features"`
SyntheticEdgeStyle map[string]string `json:"synthetic_edge_style,omitempty"`
SyntheticEdgeStyle map[string]string `json:"synthetic_edge_style,omitempty"`
}
// CityAutocomplete handles GET /v1/cities?query=.
@@ -80,17 +80,17 @@ func CityAutocomplete(hc *HandlerContext, w http.ResponseWriter, r *http.Request
// main station is closed. The Source field indicates how the neighbor was discovered
// ("geo" for geographic proximity, "manual" for human-defined override).
type CityNeighborResponse struct {
StationID string `json:"station_id"`
Name string `json:"name"`
CityCode string `json:"city_code"`
Source string `json:"source"`
StationID string `json:"station_id"`
Name string `json:"name"`
CityCode string `json:"city_code"`
Source string `json:"source"`
IsExcluded bool `json:"is_excluded"`
}
// cityStationResponse is the response for the cities/{id}/stations endpoint.
type cityStationResponse struct {
// Stations are the regular stations for the city
Stations []cityResponse `json:"stations"`
Stations []cityResponse `json:"stations"`
// Neighbors are fallback stations included when the main station is closed
Neighbors []CityNeighborResponse `json:"neighbors,omitempty"`
}
@@ -145,11 +145,11 @@ func CityStations(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
cityNeighbors := neighborTable.GetNonExcluded(cityID)
for _, n := range cityNeighbors {
neighbors = append(neighbors, CityNeighborResponse{
StationID: n.StationID,
Name: n.Name,
CityCode: n.CityCode,
Source: n.Source,
IsExcluded: n.IsExcluded,
StationID: n.StationID,
Name: n.Name,
CityCode: n.CityCode,
Source: n.Source,
IsExcluded: n.IsExcluded,
})
}
}
@@ -239,11 +239,11 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
for _, route := range results {
priceNote := "цена не указана"
routeResponses = append(routeResponses, routeSearchRoute{
Duration: route.TotalDuration,
Transfers: route.TotalTransfers,
Cost: route.Cost,
ID: route.ID,
PriceNote: priceNote,
Duration: route.TotalDuration,
Transfers: route.TotalTransfers,
Cost: route.Cost,
ID: route.ID,
PriceNote: priceNote,
})
}
@@ -318,23 +318,23 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
fromCoord, toCoord,
},
"properties": map[string]interface{}{
"transport": edge.Transport,
"transport_type": string(edge.TransportType),
"kind": fmt.Sprintf("%v", edge.Kind),
"synthetic": edge.Synthetic,
"duration": edge.Duration,
"cost": edge.Cost,
"is_transfer": edge.IsTransfer,
"stroke_color": strokeColor,
"stroke_width": 2,
"stroke_dasharray": strokeDasharray,
"transport": edge.Transport,
"transport_type": string(edge.TransportType),
"kind": fmt.Sprintf("%v", edge.Kind),
"synthetic": edge.Synthetic,
"duration": edge.Duration,
"cost": edge.Cost,
"is_transfer": edge.IsTransfer,
"stroke_color": strokeColor,
"stroke_width": 2,
"stroke_dasharray": strokeDasharray,
},
}
features = append(features, map[string]interface{}{
"type": "Feature",
"geometry": geoJsonLine,
"properties": geoJsonLine["properties"],
"type": "Feature",
"geometry": geoJsonLine,
"properties": geoJsonLine["properties"],
})
// Add transfer point markers at nodes that are transfer destinations
@@ -352,14 +352,14 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
},
},
"properties": map[string]interface{}{
"marker_type": "transfer",
"title": edge.To.Name,
"connection_time": connectionTime,
"marker_type": "transfer",
"title": edge.To.Name,
"connection_time": connectionTime,
"connection_time_formatted": fmt.Sprintf("%d min", connectionTime/60),
"transfer_type": edge.Transport,
"is_transfer": true,
"stroke_color": strokeColor,
"stroke_width": 2,
"transfer_type": edge.Transport,
"is_transfer": true,
"stroke_color": strokeColor,
"stroke_width": 2,
},
}
features = append(features, transferFeature)
@@ -368,11 +368,14 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
resp := routeGeoJSONResponse{
Type: "FeatureCollection",
Features: features,
Features: features,
SyntheticEdgeStyle: map[string]string{"stroke_dasharray": "5, 5", "stroke_color": "#ff9800"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
return
}
}
// StationStatus handles GET /v1/stations/{id}/status.
@@ -460,8 +463,8 @@ func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Reque
// Decode request body to get status and source
var req struct {
Status string `json:"status"`
Source string `json:"source"`
Status string `json:"status"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
@@ -470,8 +473,8 @@ func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Reque
// Validate status value
validStatuses := map[string]bool{
"active": true,
"closed": true,
"active": true,
"closed": true,
}
if !validStatuses[req.Status] {
http.Error(w, "invalid status value, must be 'active' or 'closed'", http.StatusBadRequest)
@@ -489,10 +492,10 @@ func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Reque
logStatusOverride(stationID, req.Status, req.Source)
resp := map[string]interface{}{
"id": stationID,
"status": req.Status,
"source": req.Source,
"message": "station status updated successfully",
"id": stationID,
"status": req.Status,
"source": req.Source,
"message": "station status updated successfully",
}
w.Header().Set("Content-Type", "application/json")
@@ -512,9 +515,9 @@ func logStatusOverride(stationID, status, source string) {
// preferenceResponse represents the response for preference endpoints.
type preferenceResponse struct {
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// getSavedCitiesHandler handles GET /v1/preferences/saved-cities.
@@ -646,13 +649,13 @@ func MetricsHandler(hc *HandlerContext, w http.ResponseWriter, r *http.Request)
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client, m *metrics.Metrics) *HandlerContext {
cacheStore := cache.NewCacheStore(redisClient, m)
return &HandlerContext{
Cache: cacheStore,
Redis: redisClient,
Router: router,
Yandex: yandex,
Cache: cacheStore,
Redis: redisClient,
Router: router,
Yandex: yandex,
SearchCache: routing.NewSearchCacheService(cacheStore, yandex, m),
Preferences: cache.NewPreferences(cacheStore),
Metrics: m,
Metrics: m,
SearchStart: time.Now(),
}
}
}

View File

@@ -10,8 +10,8 @@ import (
"github.com/go-redis/redis/v8"
"trip-planner/internal/metrics"
"trip-planner/internal/airports"
"trip-planner/internal/metrics"
"trip-planner/internal/routing"
"trip-planner/internal/storage"
"trip-planner/internal/yandex"
@@ -216,25 +216,25 @@ func TestRouteGeoJSON(t *testing.T) {
graph.AddNode(&routing.Node{ID: "s3", Type: routing.NodeTypeStation, Name: "Station 3", CityCode: "c1"})
// Add a real edge s1 → s2
graph.AddEdge(&routing.Edge{
From: graph.Nodes()[0],
To: graph.Nodes()[1],
Kind: routing.EdgeKindReal,
Duration: 3600,
Transport: "train",
From: graph.Nodes()[0],
To: graph.Nodes()[1],
Kind: routing.EdgeKindReal,
Duration: 3600,
Transport: "train",
TransportType: routing.TransportTypeTrain,
IsTransfer: false,
Synthetic: false,
IsTransfer: false,
Synthetic: false,
})
// Add a synthetic edge s2 → s3 (city↔airport transfer)
graph.AddEdge(&routing.Edge{
From: graph.Nodes()[1],
To: graph.Nodes()[2],
Kind: routing.EdgeKindSynthetic,
Duration: 300,
Transport: "train",
From: graph.Nodes()[1],
To: graph.Nodes()[2],
Kind: routing.EdgeKindSynthetic,
Duration: 300,
Transport: "train",
TransportType: routing.TransportTypeTrain,
IsTransfer: true,
Synthetic: true,
IsTransfer: true,
Synthetic: true,
})
h.Router = graph
@@ -305,25 +305,25 @@ func TestGeoJSONVisualization(t *testing.T) {
// Add real edge Moscow → Transfer
graph.AddEdge(&routing.Edge{
From: graph.Nodes()[0],
To: graph.Nodes()[1],
Kind: routing.EdgeKindReal,
Duration: 1800,
Transport: "train",
From: graph.Nodes()[0],
To: graph.Nodes()[1],
Kind: routing.EdgeKindReal,
Duration: 1800,
Transport: "train",
TransportType: routing.TransportTypeTrain,
IsTransfer: false,
Synthetic: false,
IsTransfer: false,
Synthetic: false,
})
// Add transfer edge Transfer → Destination
graph.AddEdge(&routing.Edge{
From: graph.Nodes()[1],
To: graph.Nodes()[2],
Kind: routing.EdgeKindReal,
Duration: 1800,
Transport: "train",
From: graph.Nodes()[1],
To: graph.Nodes()[2],
Kind: routing.EdgeKindReal,
Duration: 1800,
Transport: "train",
TransportType: routing.TransportTypeTrain,
IsTransfer: true,
Synthetic: false,
IsTransfer: true,
Synthetic: false,
})
h.Router = graph
@@ -356,19 +356,19 @@ func TestGeoJSONVisualization(t *testing.T) {
if geomType == "Point" {
props, ok := feature["properties"].(map[string]interface{})
if ok {
markerType, ok := props["marker_type"].(string)
if ok && markerType == "transfer" {
hasTransferMarker = true
// Verify popup-related properties exist
_, hasConnTime := props["connection_time"]
_, hasTransferType := props["transfer_type"]
if !hasConnTime {
t.Error("expected connection_time property in transfer marker")
}
if !hasTransferType {
t.Error("expected transfer_type property in transfer marker")
}
}
markerType, ok := props["marker_type"].(string)
if ok && markerType == "transfer" {
hasTransferMarker = true
// Verify popup-related properties exist
_, hasConnTime := props["connection_time"]
_, hasTransferType := props["transfer_type"]
if !hasConnTime {
t.Error("expected connection_time property in transfer marker")
}
if !hasTransferType {
t.Error("expected transfer_type property in transfer marker")
}
}
}
}
}
@@ -968,4 +968,4 @@ func TestUserPreferences(t *testing.T) {
t.Error("expected at least 1 search history entry")
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
"os"
"github.com/go-redis/redis/v8"
@@ -17,7 +18,12 @@ func main() {
redisClient := initRedis()
router := routing.NewGraph()
m := metrics.New()
yandexClient := yandex.NewClient("default-key", yandex.WithMetrics(m))
apiKey := os.Getenv("YANDEX_API_KEY")
if apiKey == "" {
apiKey = "default-key" // fallback for development
}
yandexClient := yandex.NewClient(apiKey, yandex.WithMetrics(m))
handlerCtx := NewHandlerContext(redisClient, router, yandexClient, m)
@@ -62,4 +68,4 @@ func makeHandler(handler func(*HandlerContext, http.ResponseWriter, *http.Reques
return func(w http.ResponseWriter, r *http.Request) {
handler(hc, w, r)
}
}
}

View File

@@ -175,22 +175,22 @@ func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int
// Write updated status to cache with 24h TTL
if err := sm.Cache.Set(ctx, cacheKey, []byte(newStatus), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set status: %w", err)
return newStatus, fmt.Errorf("cache set status: %w", err)
}
// Write updated zero days count to cache with 24h TTL
if err := sm.Cache.Set(ctx, zeroDaysKey, []byte(fmt.Sprintf("%d", zeroDays)), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set zero days: %w", err)
return newStatus, fmt.Errorf("cache set zero days: %w", err)
}
// Write updated zero-since timestamp to cache with 24h TTL
if err := sm.Cache.Set(ctx, zeroSinceKey, []byte(fmt.Sprintf("%d", zeroSince.Unix())), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set zero since: %w", err)
return newStatus, fmt.Errorf("cache set zero since: %w", err)
}
// Write updated last-seen-flight timestamp to cache with 24h TTL
if err := sm.Cache.Set(ctx, lastSeenFlightKey, []byte(fmt.Sprintf("%d", lastSeenFlight.Unix())), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set last seen flight: %w", err)
return newStatus, fmt.Errorf("cache set last seen flight: %w", err)
}
return newStatus, nil

View File

@@ -235,7 +235,6 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) {
}
}
// TestAutoClosureChronology verifies the chronology of auto-closure detection.
// It tests that a station closes after exactly N=3 consecutive zero-trip days,
// and that it reactivates when trips resume.
@@ -337,4 +336,3 @@ func TestAutoClosureChronology(t *testing.T) {
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays4)
}
}