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:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user