diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index ffcd8d9..d77b082 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -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(), } -} \ No newline at end of file +} diff --git a/cmd/api/handlers_test.go b/cmd/api/handlers_test.go index a24e33b..6533450 100644 --- a/cmd/api/handlers_test.go +++ b/cmd/api/handlers_test.go @@ -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") } }) -} \ No newline at end of file +} diff --git a/cmd/api/main.go b/cmd/api/main.go index fe87969..90f0fee 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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) } -} \ No newline at end of file +} diff --git a/cmd/cron/station_status.go b/cmd/cron/station_status.go index 1672e4e..76dd382 100644 --- a/cmd/cron/station_status.go +++ b/cmd/cron/station_status.go @@ -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 diff --git a/cmd/cron/station_status_test.go b/cmd/cron/station_status_test.go index c0d9cd0..4a6f0e3 100644 --- a/cmd/cron/station_status_test.go +++ b/cmd/cron/station_status_test.go @@ -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) } } - diff --git a/internal/airports/airports.go b/internal/airports/airports.go index aaf0f4c..3780d4a 100644 --- a/internal/airports/airports.go +++ b/internal/airports/airports.go @@ -35,9 +35,9 @@ type StationNeighbors struct { // NewStationNeighbors creates a new StationNeighbors instance for the given city code. func NewStationNeighbors(cityCode string) *StationNeighbors { return &StationNeighbors{ - CityCode: cityCode, + CityCode: cityCode, Neighbors: []StationNeighbor{}, - byID: make(map[string]int), + byID: make(map[string]int), } } @@ -97,4 +97,4 @@ func (sn *StationNeighbors) GetNonExcluded() []StationNeighbor { } } return result -} \ No newline at end of file +} diff --git a/internal/cache/preferences.go b/internal/cache/preferences.go index ffb17d8..9e21b5b 100644 --- a/internal/cache/preferences.go +++ b/internal/cache/preferences.go @@ -21,11 +21,11 @@ type PreferenceSavedCity struct { // PreferenceSearchHistory represents a user's search history entry. type PreferenceSearchHistory struct { - Query string `json:"query"` - FromCity string `json:"from_city"` - ToCity string `json:"to_city"` - Date string `json:"date"` - CreatedAt int64 `json:"created_at"` + Query string `json:"query"` + FromCity string `json:"from_city"` + ToCity string `json:"to_city"` + Date string `json:"date"` + CreatedAt int64 `json:"created_at"` } // Preferences represents user preferences storage. @@ -265,4 +265,4 @@ func (p *Preferences) RemoveOldSearchHistory(ctx context.Context, userID string, } return nil -} \ No newline at end of file +} diff --git a/internal/cache/store.go b/internal/cache/store.go index 130e575..8d7160f 100644 --- a/internal/cache/store.go +++ b/internal/cache/store.go @@ -40,7 +40,7 @@ type Cache interface { // redisClient is a wrapper around go-redis client for dependency injection. type redisClient struct { - client *redis.Client + client *redis.Client metrics *metrics.Metrics } @@ -54,7 +54,7 @@ func (r *redisClient) Get(ctx context.Context, key *CacheKey) ([]byte, error) { val, err := r.client.Get(ctx, keyString(key)).Bytes() if errors.Is(err, redis.Nil) { r.metrics.RecordCacheMiss("cache") // record cache miss at redis client level - return nil, nil // cache miss + return nil, nil // cache miss } if err != nil { return nil, fmt.Errorf("cache get: %w", err) @@ -167,7 +167,7 @@ func GetSearchKey(from, to, date string) *CacheKey { // CacheAside represents the cache-aside pattern implementation. // It follows the pattern: Redis → miss → Postgres/API → write-back to Redis. type CacheAside struct { - store Cache + store Cache metrics *metrics.Metrics } @@ -275,7 +275,7 @@ func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) { } if val == nil { c.metrics.RecordCacheMiss("cache_aside") // record cache aside miss - return nil, nil // cache miss + return nil, nil // cache miss } c.metrics.RecordCacheHit("cache_aside") // record cache aside hit return val, nil @@ -288,14 +288,11 @@ func (c *CacheAside) Set(ctx context.Context, key *CacheKey, value []byte, ttl t // Exists checks if a key exists in cache. func (c *CacheAside) Exists(ctx context.Context, key *CacheKey) (bool, error) { - _, err := c.store.Exists(ctx, key) - if errors.Is(err, redis.Nil) { - return false, nil - } + exists, err := c.store.Exists(ctx, key) if err != nil { return false, fmt.Errorf("cache exists: %w", err) } - return true, nil + return exists, nil } // Increment increments a counter key. @@ -306,4 +303,4 @@ func (c *CacheAside) Increment(ctx context.Context, key *CacheKey) (int64, error // Decrement decrements a counter key. func (c *CacheAside) Decrement(ctx context.Context, key *CacheKey) (int64, error) { return c.store.Decrement(ctx, key) -} \ No newline at end of file +} diff --git a/internal/cache/store_test.go b/internal/cache/store_test.go index c01de61..f91a01f 100644 --- a/internal/cache/store_test.go +++ b/internal/cache/store_test.go @@ -229,4 +229,4 @@ func TestCacheInvalidate(t *testing.T) { if err != nil { t.Fatalf("expected no error invalidating search, got: %v", err) } -} \ No newline at end of file +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 78578b1..2e02bc6 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -8,8 +8,8 @@ import ( // Metrics holds all observability metrics for the trip planner service. type Metrics struct { // Cache metrics per layer - CacheHits map[string]int64 // per-layer hit counts (city, station, search) - CacheMisses map[string]int64 // per-layer miss counts + CacheHits map[string]int64 // per-layer hit counts (city, station, search) + CacheMisses map[string]int64 // per-layer miss counts // API quota remaining (per key or global) APIQuotaRemaining int64 @@ -18,27 +18,27 @@ type Metrics struct { CircuitBreakerTrips int64 // total circuit breaker trips (opened) // Search metrics - SearchCount int64 // total number of searches - SearchDuration *histogram // distribution of search durations + SearchCount int64 // total number of searches + SearchDuration *histogram // distribution of search durations // Internal counters - mu sync.Mutex - layerTTLs map[string]time.Duration + mu sync.Mutex + layerTTLs map[string]time.Duration } // histogram tracks duration values and computes simple stats. type histogram struct { - mu sync.Mutex - values []int64 // nanoseconds - maxValues int + mu sync.Mutex + values []int64 // nanoseconds + maxValues int } // New creates a new Metrics instance with initialized maps. func New() *Metrics { return &Metrics{ - CacheHits: make(map[string]int64), - CacheMisses: make(map[string]int64), - layerTTLs: make(map[string]time.Duration), + CacheHits: make(map[string]int64), + CacheMisses: make(map[string]int64), + layerTTLs: make(map[string]time.Duration), SearchDuration: &histogram{maxValues: 1000}, } } @@ -111,14 +111,14 @@ func (m *Metrics) GetMetricsJSON() map[string]interface{} { } result := map[string]interface{}{ - "cache_hits": m.CacheHits, - "cache_misses": m.CacheMisses, - "cache_hit_rate": m.getOverallHitRate(), - "api_quota_remaining": m.APIQuotaRemaining, - "circuit_breaker_trips": m.CircuitBreakerTrips, - "search_count": m.SearchCount, + "cache_hits": m.CacheHits, + "cache_misses": m.CacheMisses, + "cache_hit_rate": m.getOverallHitRate(), + "api_quota_remaining": m.APIQuotaRemaining, + "circuit_breaker_trips": m.CircuitBreakerTrips, + "search_count": m.SearchCount, "avg_search_duration_ms": avgSearchDuration, - "layer_ttls": m.layerTTLs, + "layer_ttls": m.layerTTLs, } return result } @@ -152,4 +152,4 @@ func (m *Metrics) GetLayerTTL(layer string) (time.Duration, bool) { defer m.mu.Unlock() ttl, ok := m.layerTTLs[layer] return ttl, ok -} \ No newline at end of file +} diff --git a/internal/routing/graph.go b/internal/routing/graph.go index 2016ae4..f11f76c 100644 --- a/internal/routing/graph.go +++ b/internal/routing/graph.go @@ -13,13 +13,13 @@ type Edge struct { From *Node To *Node Kind EdgeKind - Duration int // travel time in seconds - Transport string // transport type (train, plane, bus) + Duration int // travel time in seconds + Transport string // transport type (train, plane, bus) TransportType TransportType // transport type enum - IsTransfer bool // whether this edge involves a transfer - Departure string // ISO 8601 departure time - Arrival string // ISO 8601 arrival time - Cost int // cost in minor currency units (e.g., rubles) + IsTransfer bool // whether this edge involves a transfer + Departure string // ISO 8601 departure time + Arrival string // ISO 8601 arrival time + Cost int // cost in minor currency units (e.g., rubles) // Synthetic indicates whether this edge is a synthetic transfer edge // (e.g., city↔airport, station↔city hub) rather than a real scheduled trip. Synthetic bool @@ -99,8 +99,8 @@ type Graph struct { // NewGraph creates a new empty routing graph. func NewGraph() *Graph { return &Graph{ - nodes: []*Node{}, - edges: []*Edge{}, + nodes: []*Node{}, + edges: []*Edge{}, StationNeighbors: make(map[string][]storage.StationNeighbor), } } @@ -170,26 +170,26 @@ func BuildGraphFromStations(stations []StationInfo) *Graph { tp = TransportTypeBus } graph.AddEdge(&Edge{ - From: station, - To: cityNode, - Kind: EdgeKindSynthetic, - Duration: 300, // 5 min synthetic transfer - Transport: string(tp), + From: station, + To: cityNode, + Kind: EdgeKindSynthetic, + Duration: 300, // 5 min synthetic transfer + Transport: string(tp), TransportType: tp, - IsTransfer: true, - Synthetic: true, + IsTransfer: true, + Synthetic: true, }) // Add reverse synthetic edge: city hub -> station graph.AddEdge(&Edge{ - From: cityNode, - To: station, - Kind: EdgeKindSynthetic, - Duration: 300, // 5 min synthetic transfer - Transport: string(tp), + From: cityNode, + To: station, + Kind: EdgeKindSynthetic, + Duration: 300, // 5 min synthetic transfer + Transport: string(tp), TransportType: tp, - IsTransfer: true, - Synthetic: true, + IsTransfer: true, + Synthetic: true, }) } @@ -225,26 +225,26 @@ func addSyntheticEdgesForNode(graph *Graph, node *Node) { // Add synthetic edge from node to city hub graph.AddEdge(&Edge{ - From: node, - To: n, - Kind: EdgeKindSynthetic, - Duration: duration, - Transport: string(tp), + From: node, + To: n, + Kind: EdgeKindSynthetic, + Duration: duration, + Transport: string(tp), TransportType: tp, - IsTransfer: true, - Synthetic: true, + IsTransfer: true, + Synthetic: true, }) // Add reverse synthetic edge from city hub to node graph.AddEdge(&Edge{ - From: n, - To: node, - Kind: EdgeKindSynthetic, - Duration: duration, - Transport: string(tp), + From: n, + To: node, + Kind: EdgeKindSynthetic, + Duration: duration, + Transport: string(tp), TransportType: tp, - IsTransfer: true, - Synthetic: true, + IsTransfer: true, + Synthetic: true, }) } } @@ -283,7 +283,6 @@ func (g *Graph) NodesByID(id string) *Node { func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) *Itinerary { // Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT mct := getMCTForTransfer(opts.MCT, g) - // If origin or destination station is closed, add synthetic neighbor edges as fallback if closedStations[originID] || closedStations[destID] { @@ -317,7 +316,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta for _, edge := range g.edges { if (edge.From.ID == closedStationID && edge.To.ID == neighbor.StationID) || (edge.From.ID == neighbor.StationID && edge.To.ID == closedStationID) { alreadyExists = true - break + break } } if !alreadyExists { @@ -344,7 +343,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta } } } - }// Build adjacency list from edges + } // Build adjacency list from edges adj := g.buildAdjacencyList() // BFS with transfer tracking @@ -583,12 +582,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta Legs: newLegs, TotalDuration: newDurationWithMCT, TotalTransfers: newTransfers, - Cost: current.itinerary.Cost + edge.Cost, - } + Cost: current.itinerary.Cost + edge.Cost, + } - if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers { - continue - } + if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers { + continue + } queue2 = append(queue2, bfsState{ nodeID: nextNode.ID, @@ -655,12 +654,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta } g.AddEdge(&Edge{ - From: fromNode, - To: toNode, - Duration: seg.Duration, - Transport: string(TransportTypeTrain), - IsTransfer: seg.HasTransfers, - Kind: EdgeKindReal, + From: fromNode, + To: toNode, + Duration: seg.Duration, + Transport: string(TransportTypeTrain), + IsTransfer: seg.HasTransfers, + Kind: EdgeKindReal, TransportType: TransportTypeTrain, }) } @@ -754,12 +753,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta Legs: newLegs, TotalDuration: newDurationWithMCT, TotalTransfers: newTransfers, - Cost: current.itinerary.Cost + edge.Cost, - } + Cost: current.itinerary.Cost + edge.Cost, + } - if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers { - continue - } + if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers { + continue + } queue3 = append(queue3, bfsState{ nodeID: nextNode.ID, @@ -851,16 +850,16 @@ type SearchOptions struct { // Itinerary represents a complete route with legs and summary metrics. type Itinerary struct { - Legs []RouteLeg - TotalDuration int // total travel time in seconds - TotalTransfers int // number of transfers - Cost int // cost in minor currency units (e.g., rubles) + Legs []RouteLeg + TotalDuration int // total travel time in seconds + TotalTransfers int // number of transfers + Cost int // cost in minor currency units (e.g., rubles) // Identifier for the route (e.g., search_id + route_id) - ID string + ID string // Route tracking for change detection - LastChecked int64 // Unix timestamp of last status check - NeedsReSearch bool // whether a re-search is recommended due to changes - ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay") + LastChecked int64 // Unix timestamp of last status check + NeedsReSearch bool // whether a re-search is recommended due to changes + ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay") } // RouteLeg represents a single leg of a route (one edge between two nodes). @@ -872,7 +871,7 @@ type RouteLeg struct { Duration int // travel time in seconds Transport string // transport type (train, plane, bus) IsTransfer bool - Cost int // cost in minor currency units (e.g., rubles) + Cost int // cost in minor currency units (e.g., rubles) } // SearchResult represents the result of a route search. @@ -966,9 +965,9 @@ func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions, cl // Hub stations are major transport nodes that serve as anchor points // for lazy graph expansion due to Yandex.Schedules API limitations. type HubStation struct { - ID string - Name string - CityCode string + ID string + Name string + CityCode string MinOutgoingFlights int // minimum outgoing flights criterion for hub selection } @@ -1018,6 +1017,7 @@ func SelectHubStations(stations []StationInfo, minOutgoingFlights int) []*Node { return hubs } + // getStationNeighbors returns neighboring stations for a given station ID in the same city. // RouteStatus represents the current status of a route leg. @@ -1036,7 +1036,7 @@ const ( type routeChangeReason string const ( - reasonNone routeChangeReason = "none" + reasonNone routeChangeReason = "none" reasonCancellation routeChangeReason = "cancellation" reasonMajorDelay routeChangeReason = "major_delay" ) @@ -1123,10 +1123,10 @@ func getStationNeighbors(stationID string, g *Graph) []storage.StationNeighbor { for _, n := range g.Nodes() { if n.ID != stationID && n.CityCode == cityCode && n.Type == NodeTypeStation { neighbors = append(neighbors, storage.StationNeighbor{ - StationID: n.ID, - Name: n.Name, - CityCode: n.CityCode, - Source: "geo", + StationID: n.ID, + Name: n.Name, + CityCode: n.CityCode, + Source: "geo", IsExcluded: false, }) } diff --git a/internal/routing/graph_test.go b/internal/routing/graph_test.go index c0ac502..7e2b06e 100644 --- a/internal/routing/graph_test.go +++ b/internal/routing/graph_test.go @@ -18,25 +18,25 @@ func TestFindRouteMaxTransfers(t *testing.T) { // Add direct edge s1 -> s6 (0 transfers) graph.AddEdge(&Edge{ - From: graph.Nodes()[0], // s1 - To: graph.Nodes()[5], // s6 - Kind: EdgeKindReal, - Duration: 3600, - Transport: "train", + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[5], // s6 + Kind: EdgeKindReal, + Duration: 3600, + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: false, + IsTransfer: false, }) // Add chain edges s1->s2->s3->s4->s5->s6 (each is a transfer edge) for i := 0; i < 5; i++ { graph.AddEdge(&Edge{ - From: graph.Nodes()[i], - To: graph.Nodes()[i+1], - Kind: EdgeKindReal, - Duration: 1000, - Transport: "train", + From: graph.Nodes()[i], + To: graph.Nodes()[i+1], + Kind: EdgeKindReal, + Duration: 1000, + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: true, + IsTransfer: true, }) } @@ -105,68 +105,68 @@ func TestParetoFrontGeneration(t *testing.T) { // Add direct edge s1 -> s8 (0 transfers, higher cost) graph.AddEdge(&Edge{ - From: graph.Nodes()[0], // s1 - To: graph.Nodes()[7], // s8 - Kind: EdgeKindReal, - Duration: 600, // 10 min - Transport: "train", + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[7], // s8 + Kind: EdgeKindReal, + Duration: 600, // 10 min + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: false, - Cost: 500, // expensive direct + IsTransfer: false, + Cost: 500, // expensive direct }) // Add 1-transfer route s1->s3->s8 (lower cost, more time) graph.AddEdge(&Edge{ - From: graph.Nodes()[0], // s1 - To: graph.Nodes()[2], // s3 - Kind: EdgeKindReal, - Duration: 200, // 3 min - Transport: "train", + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[2], // s3 + Kind: EdgeKindReal, + Duration: 200, // 3 min + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: true, - Cost: 200, + IsTransfer: true, + Cost: 200, }) graph.AddEdge(&Edge{ - From: graph.Nodes()[2], // s3 - To: graph.Nodes()[7], // s8 - Kind: EdgeKindReal, - Duration: 300, // 5 min - Transport: "train", + From: graph.Nodes()[2], // s3 + To: graph.Nodes()[7], // s8 + Kind: EdgeKindReal, + Duration: 300, // 5 min + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: true, - Cost: 100, + IsTransfer: true, + Cost: 100, }) // Add 2-transfer route s1->s5->s6->s8 (even lower cost, more transfers) graph.AddEdge(&Edge{ - From: graph.Nodes()[0], // s1 - To: graph.Nodes()[4], // s5 - Kind: EdgeKindReal, - Duration: 100, // 2 min - Transport: "train", + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[4], // s5 + Kind: EdgeKindReal, + Duration: 100, // 2 min + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: true, - Cost: 100, + IsTransfer: true, + Cost: 100, }) graph.AddEdge(&Edge{ - From: graph.Nodes()[4], // s5 - To: graph.Nodes()[5], // s6 - Kind: EdgeKindReal, - Duration: 100, // 2 min - Transport: "train", + From: graph.Nodes()[4], // s5 + To: graph.Nodes()[5], // s6 + Kind: EdgeKindReal, + Duration: 100, // 2 min + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: true, - Cost: 50, + IsTransfer: true, + Cost: 50, }) graph.AddEdge(&Edge{ - From: graph.Nodes()[5], // s6 - To: graph.Nodes()[7], // s8 - Kind: EdgeKindReal, - Duration: 200, // 3 min - Transport: "train", + From: graph.Nodes()[5], // s6 + To: graph.Nodes()[7], // s8 + Kind: EdgeKindReal, + Duration: 200, // 3 min + Transport: "train", TransportType: TransportTypeTrain, - IsTransfer: true, - Cost: 50, + IsTransfer: true, + Cost: 50, }) t.Run("fastest mode (default) sorts by duration", func(t *testing.T) { @@ -285,13 +285,13 @@ func TestLazyExpansionDepthLimit(t *testing.T) { // Add chain of transfer edges s1->s2->s3->s4->s5->s6->s7 for i := 0; i < 6; i++ { graph.AddEdge(&Edge{ - From: graph.Nodes()[i], - To: graph.Nodes()[i+1], - Kind: EdgeKindReal, - Duration: 100, - Transport: "train", - TransportType: TransportTypeTrain, - IsTransfer: true, + From: graph.Nodes()[i], + To: graph.Nodes()[i+1], + Kind: EdgeKindReal, + Duration: 100, + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: true, }) } @@ -336,6 +336,7 @@ func TestLazyExpansionDepthLimit(t *testing.T) { t.Log("MaxTransfers=0: no direct route s1->s7 found (only chain edges exist)") } } + // TestRouteReSearchOnChange tests that the route change detection logic correctly // identifies when a route leg has undergone significant changes (cancellation or major delay) // and triggers a re-search to find an updated route. @@ -355,7 +356,7 @@ func TestRouteReSearchOnChange(t *testing.T) { Duration: 3600, // 1 hour Transport: "train", IsTransfer: false, - Cost: 500, + Cost: 500, }) // Add real edge s2 -> s3 (direct route) @@ -366,7 +367,7 @@ func TestRouteReSearchOnChange(t *testing.T) { Duration: 3600, // 1 hour Transport: "train", IsTransfer: false, - Cost: 500, + Cost: 500, }) // Create an itinerary simulating a found route from s1 to s3 @@ -375,13 +376,13 @@ func TestRouteReSearchOnChange(t *testing.T) { {From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, {From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, }, - TotalDuration: 7200, // 2 hours total + TotalDuration: 7200, // 2 hours total TotalTransfers: 0, - ID: "test-route-123", + ID: "test-route-123", // Set LastChecked to 2 hours ago (7200 seconds) to force re-check // The check skips if checked within 3600 seconds (1 hour) - LastChecked: time.Now().Unix() - 7200, - NeedsReSearch: false, + LastChecked: time.Now().Unix() - 7200, + NeedsReSearch: false, ReSearchReason: "", } @@ -431,11 +432,11 @@ func TestRouteReSearchOnChange(t *testing.T) { {From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, {From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, }, - TotalDuration: 7200, + TotalDuration: 7200, TotalTransfers: 0, - ID: "test-route-456", - LastChecked: time.Now().Unix() - 7200, - NeedsReSearch: false, + ID: "test-route-456", + LastChecked: time.Now().Unix() - 7200, + NeedsReSearch: false, ReSearchReason: "", } diff --git a/internal/routing/search_cache.go b/internal/routing/search_cache.go index 72da1ad..495870e 100644 --- a/internal/routing/search_cache.go +++ b/internal/routing/search_cache.go @@ -12,9 +12,9 @@ import ( // SearchCacheService handles caching and on-demand Yandex /search calls. type SearchCacheService struct { - cache *cache.CacheAside - yclient *yandex.Client - metrics *metrics.Metrics + cache *cache.CacheAside + yclient *yandex.Client + metrics *metrics.Metrics } // NewSearchCacheService creates a new search cache service. @@ -28,7 +28,7 @@ func NewSearchCacheService(cacheStore cache.Cache, yclient *yandex.Client, m *me // SearchWithCache performs a route search with caching support. // It uses the cache-aside pattern: try cache first, then Yandex API, then write back to cache. -func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date string, opts SearchOptions) (*Itinerary, error) { +func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date string, opts SearchOptions) (*yandex.Response, error) { // Generate cache key searchKey := cache.GetSearchKey(from, to, date) @@ -45,10 +45,10 @@ func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date return nil, fmt.Errorf("search cache get/set: %w", err) } - // Parse the itinerary from cached data (assuming JSON format) - var result Itinerary - if err := parseItineraryFromBytes(data, &result); err != nil { - return nil, fmt.Errorf("failed to parse itinerary from cache: %w", err) + // Parse the yandex.Response from cached data + var result yandex.Response + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse yandex response from cache: %w", err) } return &result, nil @@ -73,17 +73,6 @@ func (s *SearchCacheService) performYandexSearch(ctx context.Context, from, to, return convertResponseToBytes(resp) } -// parseItineraryFromBytes parses an itinerary from byte data. -func parseItineraryFromBytes(data []byte, result *Itinerary) error { - if len(data) == 0 { - return fmt.Errorf("empty data") - } - if err := json.Unmarshal(data, result); err != nil { - return fmt.Errorf("failed to parse itinerary from bytes: %w", err) - } - return nil -} - // convertResponseToBytes converts Yandex API response to bytes for caching. func convertResponseToBytes(resp *yandex.Response) ([]byte, error) { if resp == nil { diff --git a/internal/routing/search_cache_test.go b/internal/routing/search_cache_test.go index 879c53e..32a923c 100644 --- a/internal/routing/search_cache_test.go +++ b/internal/routing/search_cache_test.go @@ -69,21 +69,6 @@ func TestNewSearchCacheService(t *testing.T) { } } -func TestParseItineraryFromBytes(t *testing.T) { - // Test with empty data - result := Itinerary{} - err := parseItineraryFromBytes([]byte{}, &result) - if err == nil { - t.Error("expected error for empty data") - } - - // Test with invalid JSON data - err = parseItineraryFromBytes([]byte("invalid json data"), &result) - if err == nil { - t.Error("expected error for invalid JSON data") - } -} - func TestConvertResponseToBytes(t *testing.T) { // Test with nil response _, err := convertResponseToBytes(nil) diff --git a/internal/storage/neighbors.go b/internal/storage/neighbors.go index 3ec1801..17fa46a 100644 --- a/internal/storage/neighbors.go +++ b/internal/storage/neighbors.go @@ -65,7 +65,7 @@ func (snt *StationNeighborsTable) MarkExcluded(cityCode, stationID string) { // GetNonExcluded returns non-excluded neighbors for a given city code. func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeighbor { if neighbors, ok := snt.neighbors[cityCode]; ok { - var result []StationNeighbor + var result []StationNeighbor for _, n := range neighbors { if !n.IsExcluded { result = append(result, n) @@ -74,4 +74,4 @@ func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeigh return result } return nil -} \ No newline at end of file +} diff --git a/internal/storage/transfer_rules.go b/internal/storage/transfer_rules.go index 04f49fd..8261bef 100644 --- a/internal/storage/transfer_rules.go +++ b/internal/storage/transfer_rules.go @@ -2,8 +2,8 @@ package storage // TransferRule represents a minimum connection time rule. type TransferRule struct { - RuleKey string `json:"rule_key"` - MinTransferTimeMinutes int `json:"min_transfer_time_minutes"` + RuleKey string `json:"rule_key"` + MinTransferTimeMinutes int `json:"min_transfer_time_minutes"` } // TransferRuleMap is a lookup map for MCT values. @@ -42,4 +42,4 @@ func ExtractBaseKey(ruleKey string) string { } // DefaultMCT is the default minimum connection time in seconds (30 minutes). -const DefaultMCT = 1800 // 30 minutes \ No newline at end of file +const DefaultMCT = 1800 // 30 minutes diff --git a/internal/yandex/client.go b/internal/yandex/client.go index 777a008..be84dd7 100644 --- a/internal/yandex/client.go +++ b/internal/yandex/client.go @@ -325,11 +325,15 @@ func (tb *tokenBucket) acquire() error { func (tb *tokenBucket) refill(now time.Time) { elapsed := now.Sub(tb.lastRefill) if elapsed >= time.Second { - // Refill tokens based on elapsed time and rate - tb.tokens = tb.capacity + tokensToAdd := int(elapsed.Seconds()) * tb.refillPerSec + if tb.tokens+tokensToAdd > tb.capacity { + tb.tokens = tb.capacity + } else { + tb.tokens += tokensToAdd + } tb.lastRefill = now } - // else: keep current tokens, will fully refill on next second boundary + // else: keep current tokens, will add on next refill } // --- Circuit Breaker --- @@ -342,7 +346,6 @@ func newCircuitBreaker() *circuitBreaker { } } - func (cb *circuitBreaker) ResetCircuitBreaker() { cb.mu.Lock() defer cb.mu.Unlock() @@ -420,4 +423,4 @@ func applyJitter(backoff time.Duration) time.Duration { func randFloat64() float64 { // Use math/rand with a seed based on function call index for variability return rand.Float64() -} \ No newline at end of file +}