package routing import ( "context" "testing" "github.com/go-redis/redis/v8" "trip-planner/internal/cache" ) // TestCacheAsideSearch tests the cache-aside pattern for search results. // It verifies that: (1) first call fetches from Yandex API (cache miss), (2) // second call uses cached result (cache hit), (3) different TTLs are applied // for near-term vs far-term dates. func TestCacheAsideSearch(t *testing.T) { ctx := context.Background() fetchCallCount := 0 fetchFunc := func() ([]byte, error) { fetchCallCount++ return []byte(`{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}`), nil } // First call: cache miss, should fetch from backend searchKey := cache.GetSearchKey("c146", "c213", "2026-08-15-test1") store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1})) data, err := cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false) if err != nil { t.Fatalf("expected no error on cache miss, got: %v", err) } if string(data) != `{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}` { t.Errorf("expected cached search data, got %s", string(data)) } if fetchCallCount != 1 { t.Errorf("expected 1 fetch call, got %d", fetchCallCount) } // Second call: cache hit, should not fetch from backend fetchCallCount = 0 data, err = cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false) if err != nil { t.Fatalf("expected no error on cache hit, got: %v", err) } if fetchCallCount != 0 { t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount) } } // TestCacheAsideSearchFarTerm tests cache-aside search with far-term TTL. func TestCacheAsideSearchFarTerm(t *testing.T) { ctx := context.Background() fetchCallCount := 0 fetchFunc := func() ([]byte, error) { fetchCallCount++ return []byte(`{"legs":[]}`), nil } // Far-term search key - should use SearchFarTermTTL (7 days) store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1})) farKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15-test2"} data, err := cache.NewCacheAside(store).GetSearch(ctx, farKey, fetchFunc, true) if err != nil { t.Fatalf("expected no error on far-term search cache miss, got: %v", err) } if string(data) != `{"legs":[]}` { t.Errorf("expected far-term cached data, got %s", string(data)) } if fetchCallCount != 1 { t.Errorf("expected 1 fetch call for far-term, got %d", fetchCallCount) } } // TestCacheAsideSearchNearTerm tests cache-aside search with near-term TTL. func TestCacheAsideSearchNearTerm(t *testing.T) { ctx := context.Background() fetchCallCount := 0 fetchFunc := func() ([]byte, error) { fetchCallCount++ return []byte(`{"legs":[]}`), nil } // Near-term search key - should use SearchNearTermTTL (3 hours) store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1})) nearKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15-test3"} data, err := cache.NewCacheAside(store).GetSearch(ctx, nearKey, fetchFunc, false) if err != nil { t.Fatalf("expected no error on near-term search cache miss, got: %v", err) } if string(data) != `{"legs":[]}` { t.Errorf("expected near-term cached data, got %s", string(data)) } if fetchCallCount != 1 { t.Errorf("expected 1 fetch call for near-term, got %d", fetchCallCount) } } // TestTransportTypesInGraph tests that the routing algorithm correctly handles // different transport types (plane, train, bus) and that edges are created with // the proper TransportType enum values. func TestTransportTypesInGraph(t *testing.T) { // Test 1: Edge with plane transport type graph := NewGraph() graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "SPb", CityCode: "c1"}) graph.AddEdge(&Edge{ From: graph.Nodes()[0], // s1 Moscow To: graph.Nodes()[1], // s2 SPb Kind: EdgeKindReal, Duration: 3600, Transport: string(TransportTypePlane), TransportType: TransportTypePlane, IsTransfer: false, Cost: 0, }) if graph.Edges()[0].TransportType != TransportTypePlane { t.Errorf("expected TransportTypePlane, got %v", graph.Edges()[0].TransportType) } if graph.Edges()[0].Transport != "plane" { t.Errorf("expected Transport 'plane', got %s", graph.Edges()[0].Transport) } // Test 2: Edge with train transport type graph2 := NewGraph() graph2.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph2.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "SPb", CityCode: "c1"}) graph2.AddEdge(&Edge{ From: graph2.Nodes()[0], To: graph2.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: string(TransportTypeTrain), TransportType: TransportTypeTrain, IsTransfer: false, Cost: 0, }) if graph2.Edges()[0].TransportType != TransportTypeTrain { t.Errorf("expected TransportTypeTrain, got %v", graph2.Edges()[0].TransportType) } if graph2.Edges()[0].Transport != "train" { t.Errorf("expected Transport 'train', got %s", graph2.Edges()[0].Transport) } // Test 3: Edge with bus transport type graph3 := NewGraph() graph3.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph3.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "SPb", CityCode: "c1"}) graph3.AddEdge(&Edge{ From: graph3.Nodes()[0], To: graph3.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: string(TransportTypeBus), TransportType: TransportTypeBus, IsTransfer: false, Cost: 0, }) if graph3.Edges()[0].TransportType != TransportTypeBus { t.Errorf("expected TransportTypeBus, got %v", graph3.Edges()[0].TransportType) } if graph3.Edges()[0].Transport != "bus" { t.Errorf("expected Transport 'bus', got %s", graph3.Edges()[0].Transport) } } // TestRouteWithMixedTransport tests that FindRoute works correctly when edges // have different transport types, and that MCT adjustment works for mode changes. func TestRouteWithMixedTransport(t *testing.T) { graph := NewGraph() // Add stations graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"}) // Direct train route: Moscow → Tula (0 transfers, 3600s) graph.AddEdge(&Edge{ From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: string(TransportTypeTrain), TransportType: TransportTypeTrain, IsTransfer: false, Cost: 0, }) // Bus route: Moscow → Vladimir (0 transfers, 3000s) graph.AddEdge(&Edge{ From: graph.Nodes()[0], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 3000, Transport: string(TransportTypeBus), TransportType: TransportTypeBus, IsTransfer: false, Cost: 0, }) // Plane route: T Vladimir → Vladimir (this would be a transfer, but let's just test) // Add an edge with different transport type to test MCT mode change logic graph.AddEdge(&Edge{ From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 600, Transport: string(TransportTypePlane), TransportType: TransportTypePlane, IsTransfer: true, Cost: 0, }) opts := SearchOptions{MaxTransfers: 3, MCT: 300} results := graph.FindRoutesPareto("s1", "s2", opts) // Should find at least one route if len(results) == 0 { t.Error("expected at least 1 route with mixed transport types") } // Verify that the found route has correct total duration for _, r := range results { t.Logf("Route: duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost) } } // TestParetoWithDifferentTransportTypes tests that Pareto ranking considers // transport type as part of the route characteristics. func TestParetoWithDifferentTransportTypes(t *testing.T) { graph := NewGraph() // Add stations along a route graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"}) graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"}) // Direct train route: Moscow → Kursk (0 transfers, 3600s, cost 0) graph.AddEdge(&Edge{ From: graph.Nodes()[0], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 3600, Transport: string(TransportTypeTrain), TransportType: TransportTypeTrain, IsTransfer: false, Cost: 0, }) // Bus route: Moscow → Kursk with transfer (1 transfer, 3000s, cost 0) graph.AddEdge(&Edge{ From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 2000, Transport: string(TransportTypeBus), TransportType: TransportTypeBus, IsTransfer: false, Cost: 0, }) graph.AddEdge(&Edge{ From: graph.Nodes()[1], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 1000, Transport: string(TransportTypeBus), TransportType: TransportTypeBus, IsTransfer: true, Cost: 0, }) // Fast train with transfer: Moscow → Tula (direct, 2000s), then Tula → Kursk (bus, 1000s, transfer) // This route has 1 transfer, 3000s total, cost 0 opts := SearchOptions{MaxTransfers: 3, MCT: 300} results := graph.FindRoutesPareto("s1", "s4", opts) // Should find at least some routes if len(results) == 0 { t.Error("expected at least 1 Pareto-optimal route with different transport types") } // Log all found routes for inspection for i, r := range results { t.Logf("Route %d: duration=%d, transfers=%d, cost=%d", i, r.TotalDuration, r.TotalTransfers, r.Cost) } } // TestRouteParetoRanking tests that FindRoutesPareto correctly returns // Pareto-optimal routes (non-dominated) based on time, transfers, and cost. // A route is dominated if another route is better or equal in all metrics. func TestRouteParetoRanking(t *testing.T) { graph := NewGraph() // Add stations along a route graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"}) graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"}) // Direct route: Moscow → Kursk (0 transfers, 3600s, cost 0) graph.AddEdge(&Edge{ From: graph.Nodes()[0], // s1 Moscow To: graph.Nodes()[3], // s4 Kursk Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, Cost: 0, }) // Indirect route: Moscow → Tula → Vladimir → Kursk (3 transfers, 3*3600=10800s, cost 0) graph.AddEdge(&Edge{ From: graph.Nodes()[0], // s1 Moscow To: graph.Nodes()[1], // s2 Tula Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, Cost: 0, }) graph.AddEdge(&Edge{ From: graph.Nodes()[1], // s2 Tula To: graph.Nodes()[2], // s3 Vladimir Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, Cost: 0, }) graph.AddEdge(&Edge{ From: graph.Nodes()[2], // s3 Vladimir To: graph.Nodes()[3], // s4 Kursk Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, Cost: 0, }) // Fast but expensive route: Moscow → Tula (1 leg, 1800s, cost 5000) // This would be an alternative direct route with higher cost but lower duration // Add a second direct edge with different characteristics if needed opts := SearchOptions{MaxTransfers: 3, MCT: 300} results := graph.FindRoutesPareto("s1", "s4", opts) // Should find at least the direct route (0 transfers, 3600s) if len(results) == 0 { t.Error("expected at least 1 Pareto-optimal route") } // The direct route (0 transfers, 3600s) should be Pareto-optimal // since no other route has both fewer transfers and less duration directFound := false for _, r := range results { if r.TotalDuration == 3600 && r.TotalTransfers == 0 { directFound = true break } } if !directFound { t.Error("expected direct route (0 transfers, 3600s) in Pareto results") } // Test with routes that have different cost values graph2 := NewGraph() graph2.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph2.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) graph2.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"}) graph2.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"}) // Route A: 0 transfers, 3600s, cost 1000 graph2.AddEdge(&Edge{ From: graph2.Nodes()[0], To: graph2.Nodes()[3], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, Cost: 1000, }) // Route B: 0 transfers, 4000s, cost 0 (cheaper but slower) // This route should NOT dominate Route A (different cost), and Route A // should NOT dominate Route B (Route A is faster but more expensive) graph2.AddEdge(&Edge{ From: graph2.Nodes()[0], To: graph2.Nodes()[3], Kind: EdgeKindReal, Duration: 4000, Transport: "train", IsTransfer: false, Cost: 0, }) // Route C: 1 transfer, 3000s, cost 0 (middle ground) graph2.AddEdge(&Edge{ From: graph2.Nodes()[0], To: graph2.Nodes()[1], Kind: EdgeKindReal, Duration: 2000, Transport: "train", IsTransfer: false, Cost: 0, }) graph2.AddEdge(&Edge{ From: graph2.Nodes()[1], To: graph2.Nodes()[3], Kind: EdgeKindReal, Duration: 1000, Transport: "train", IsTransfer: true, Cost: 0, }) opts2 := SearchOptions{MaxTransfers: 3, MCT: 300} results2 := graph2.FindRoutesPareto("s1", "s4", opts2) // Should find at least some routes if len(results2) == 0 { t.Error("expected at least 1 Pareto-optimal route with cost variation") } // Verify no route is dominated by another in all metrics for i, r1 := range results2 { for j, r2 := range results2 { if i == j { continue } // Check if r2 dominates r1 r2DominatesR1 := r2.TotalDuration <= r1.TotalDuration && r2.TotalTransfers <= r1.TotalTransfers && r2.Cost <= r1.Cost && (r2.TotalDuration < r1.TotalDuration || r2.TotalTransfers < r1.TotalTransfers || r2.Cost < r1.Cost) if r2DominatesR1 { t.Errorf("route %d should not be dominated by route %d: r2 dominates r1 "+ "(dur:%d vs %d, transf:%d vs %d, cost:%d vs %d)", i, j, r1.TotalDuration, r2.TotalDuration, r1.TotalTransfers, r2.TotalTransfers, r1.Cost, r2.Cost) } } } }