package main import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-redis/redis/v8" "trip-planner/internal/airports" "trip-planner/internal/routing" "trip-planner/internal/storage" "trip-planner/internal/yandex" ) func flushRedisForTest(t *testing.T, client *redis.Client) { // Clear preference-related keys from Redis to ensure test isolation // The keyString sanitization prepends "unknown:" and replaces ":" with "_colon_" // Actual keys look like: "unknown:prefs_colon_saved_city_colon_testuser1" keys, err := client.Keys(context.Background(), "unknown:prefs*").Result() if err != nil { t.Logf("warning: could not flush preference keys: %v", err) return } for _, key := range keys { if err := client.Del(context.Background(), key).Err(); err != nil { t.Logf("warning: could not delete key %s: %v", key, err) } } // Also delete search_history keys keys2, err := client.Keys(context.Background(), "unknown:search_history*").Result() if err != nil { t.Logf("warning: could not flush search history keys: %v", err) return } for _, key := range keys2 { if err := client.Del(context.Background(), key).Err(); err != nil { t.Logf("warning: could not delete key %s: %v", key, err) } } } func newMockHandlerContext() *HandlerContext { redisClient := redis.NewClient(&redis.Options{ Addr: "localhost:6379", }) // Create an empty routing graph router := routing.NewGraph() // Create Yandex client yandexClient := yandex.NewClient("test-key") return NewHandlerContext(redisClient, router, yandexClient) } func TestHandlerCityAutocomplete(t *testing.T) { h := newMockHandlerContext() req := httptest.NewRequest("GET", "/v1/cities?query=mos", nil) rr := httptest.NewRecorder() CityAutocomplete(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp []cityResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } t.Logf("city autocomplete response: %d cities", len(resp)) } func TestHandlerCityStations(t *testing.T) { h := newMockHandlerContext() req := httptest.NewRequest("GET", "/v1/cities/1/stations", nil) rr := httptest.NewRecorder() CityStations(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } } func TestHandlerCityStationsWithNeighbors(t *testing.T) { h := newMockHandlerContext() // Test: City 1 with closed station should include neighbors req := httptest.NewRequest("GET", "/v1/cities/1/stations", nil) rr := httptest.NewRecorder() CityStations(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp cityStationResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } // Should have stations if len(resp.Stations) == 0 { t.Error("expected at least one station") } t.Logf("City 1 stations: %+v", resp.Stations) // Should have neighbors when station is closed (city 1 has closed stations) if len(resp.Neighbors) == 0 { t.Error("expected neighboring stations for closed main station") } t.Logf("City 1 neighbors: %+v", resp.Neighbors) // Verify neighbor has source field for _, n := range resp.Neighbors { if n.Source != "manual" && n.Source != "geo" { t.Errorf("expected neighbor source to be 'manual' or 'geo', got %s", n.Source) } t.Logf(" Neighbor: %s (source=%s, isExcluded=%v)", n.Name, n.Source, n.IsExcluded) } } func TestHandlerRouteSearch(t *testing.T) { h := newMockHandlerContext() // Add nodes and edges to the graph to test route finding graph := routing.NewGraph() graph.AddNode(&routing.Node{ID: "c146", Type: routing.NodeTypeCity, Name: "Simferopol"}) graph.AddNode(&routing.Node{ID: "c213", Type: routing.NodeTypeCity, Name: "Moscow"}) graph.AddNode(&routing.Node{ID: "s9600213", Type: routing.NodeTypeStation, Name: "Шереметьево", CityCode: "c146"}) graph.AddNode(&routing.Node{ID: "s9600396", Type: routing.NodeTypeStation, Name: "Симферополь", CityCode: "c146"}) // Add synthetic edges: station <-> city graph.AddEdge(&routing.Edge{ From: graph.Nodes()[2], // s9600213 To: graph.Nodes()[0], // c146 Kind: routing.EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, }) graph.AddEdge(&routing.Edge{ From: graph.Nodes()[0], // c146 To: graph.Nodes()[2], // s9600213 Kind: routing.EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, }) graph.AddEdge(&routing.Edge{ From: graph.Nodes()[3], // s9600396 To: graph.Nodes()[0], // c146 Kind: routing.EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, }) graph.AddEdge(&routing.Edge{ From: graph.Nodes()[0], // c146 To: graph.Nodes()[3], // s9600396 Kind: routing.EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, }) // Replace the router with our test graph h.Router = graph // Create request with JSON body req := httptest.NewRequest("POST", "/v1/routes/search", strings.NewReader(`{"from_city_id": "c146", "to_city_id": "c213", "date": "2026-08-15"}`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() RouteSearch(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp routeSearchResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) t.Logf("route search response: routes=%+v, count=%d", resp.Routes, resp.Count) } } func TestHandlerRouteGeoJSON(t *testing.T) { h := newMockHandlerContext() req := httptest.NewRequest("GET", "/v1/routes/search-123/route-456/geojson", nil) rr := httptest.NewRecorder() RouteGeoJSON(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp routeGeoJSONResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } t.Logf("route geojson response: %+v", resp) } func TestRouteGeoJSON(t *testing.T) { h := newMockHandlerContext() // Add edges to the graph to test GeoJSON generation graph := routing.NewGraph() graph.AddNode(&routing.Node{ID: "s1", Type: routing.NodeTypeStation, Name: "Station 1", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s2", Type: routing.NodeTypeStation, Name: "Station 2", CityCode: "c1"}) 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", TransportType: routing.TransportTypeTrain, 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", TransportType: routing.TransportTypeTrain, IsTransfer: true, Synthetic: true, }) h.Router = graph req := httptest.NewRequest("GET", "/v1/routes/search-123/route-456/geojson", nil) rr := httptest.NewRecorder() RouteGeoJSON(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp routeGeoJSONResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } // Debug: print all feature types and properties t.Logf("Total features: %d", len(resp.Features)) for i, feature := range resp.Features { geom, _ := feature["geometry"].(map[string]interface{}) t.Logf("Feature %d: geom_type=%s", i, geom["type"]) props, _ := feature["properties"].(map[string]interface{}) if props != nil { t.Logf("Feature %d props: %+v", i, props) } } // Should have features for both real and synthetic edges if len(resp.Features) == 0 { t.Error("expected at least one feature in GeoJSON response") } // Check that real and synthetic edges have different stroke styles hasReal := false hasSynthetic := false for _, feature := range resp.Features { props, ok := feature["properties"].(map[string]interface{}) if !ok { continue } dasharray, _ := props["stroke_dasharray"].(string) t.Logf("Checking feature: dasharray=%s", dasharray) if dasharray == "" { hasReal = true } if dasharray == "5, 5" { hasSynthetic = true } } if !hasReal { t.Error("expected at least one real edge with solid line (no dasharray)") } if !hasSynthetic { t.Error("expected at least one synthetic edge with dashed line (dasharray=5, 5)") } t.Logf("GeoJSON response has %d features", len(resp.Features)) } func TestGeoJSONVisualization(t *testing.T) { h := newMockHandlerContext() // Add a route with multiple legs and transfer points graph := routing.NewGraph() graph.AddNode(&routing.Node{ID: "s1", Type: routing.NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s2", Type: routing.NodeTypeStation, Name: "Transfer Station", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s3", Type: routing.NodeTypeStation, Name: "Destination", CityCode: "c1"}) // Add real edge Moscow → Transfer graph.AddEdge(&routing.Edge{ From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: routing.EdgeKindReal, Duration: 1800, Transport: "train", TransportType: routing.TransportTypeTrain, 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", TransportType: routing.TransportTypeTrain, IsTransfer: true, Synthetic: false, }) h.Router = graph req := httptest.NewRequest("GET", "/v1/routes/search-123/route-456/geojson", nil) rr := httptest.NewRecorder() RouteGeoJSON(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp routeGeoJSONResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } // Should have features for edges if len(resp.Features) == 0 { t.Error("expected at least one feature in GeoJSON response") } // Check for transfer point markers (Point geometry with marker_type=transfer) hasTransferMarker := false for _, feature := range resp.Features { geom, ok := feature["geometry"].(map[string]interface{}) if !ok { continue } geomType, _ := geom["type"].(string) 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") } } } } } if !hasTransferMarker { t.Error("expected at least one transfer point marker with popup info") } t.Logf("GeoJSON visualization has %d features, including transfer markers", len(resp.Features)) } func TestHandlerStationStatus(t *testing.T) { h := newMockHandlerContext() req := httptest.NewRequest("GET", "/v1/stations/s9600213/status", nil) rr := httptest.NewRecorder() StationStatus(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp stationStatusResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } t.Logf("station status response: %+v", resp) } func TestAdminAuth(t *testing.T) { h := newMockHandlerContext() // Test 1: Request without API key should be unauthorized req := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "closed", "source": "manual"}`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() AdminStationStatus(h, rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected status 401 (unauthorized) without API key, got %d", rr.Code) } t.Logf("Test admin auth (no key): got status %d (expected 401)", rr.Code) // Test 2: Request with correct API key should be authorized req2 := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "closed", "source": "manual"}`)) req2.Header.Set("Content-Type", "application/json") req2.Header.Set("X-Admin-Api-Key", "trip-planner-admin-key") rr2 := httptest.NewRecorder() AdminStationStatus(h, rr2, req2) if rr2.Code != http.StatusOK { t.Errorf("expected status 200 with valid API key, got %d", rr2.Code) } t.Logf("Test admin auth (valid key): got status %d (expected 200)", rr2.Code) // Test 3: Request with wrong API key should be unauthorized req3 := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "closed", "source": "manual"}`)) req3.Header.Set("Content-Type", "application/json") req3.Header.Set("X-Admin-Api-Key", "wrong-key") rr3 := httptest.NewRecorder() AdminStationStatus(h, rr3, req3) if rr3.Code != http.StatusUnauthorized { t.Errorf("expected status 401 (unauthorized) with wrong API key, got %d", rr3.Code) } t.Logf("Test admin auth (wrong key): got status %d (expected 401)", rr3.Code) } func TestAdminStationStatus(t *testing.T) { h := newMockHandlerContext() // Set up a station in the graph graph := routing.NewGraph() graph.AddNode(&routing.Node{ID: "s9600213", Type: routing.NodeTypeStation, Name: "Sheremetyevo", CityCode: "c146"}) h.Router = graph // Test 1: Set station status to "closed" with manual source req := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "closed", "source": "manual"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Admin-Api-Key", "trip-planner-admin-key") rr := httptest.NewRecorder() AdminStationStatus(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp map[string]interface{} if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp["status"] != "closed" { t.Errorf("expected status 'closed', got '%v'", resp["status"]) } if resp["source"] != "manual" { t.Errorf("expected source 'manual', got '%v'", resp["source"]) } if resp["id"] != "s9600213" { t.Errorf("expected id 's9600213', got '%v'", resp["id"]) } t.Logf("Test admin station status (closed): %+v", resp) // Test 2: Set station status to "active" with manual source req2 := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "active", "source": "manual"}`)) req2.Header.Set("Content-Type", "application/json") req2.Header.Set("X-Admin-Api-Key", "trip-planner-admin-key") rr2 := httptest.NewRecorder() AdminStationStatus(h, rr2, req2) if rr2.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr2.Code) } var resp2 map[string]interface{} if err := json.Unmarshal(rr2.Body.Bytes(), &resp2); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp2["status"] != "active" { t.Errorf("expected status 'active', got '%v'", resp2["status"]) } t.Logf("Test admin station status (active): %+v", resp2) // Test 3: Invalid status value should return 400 req3 := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "invalid", "source": "manual"}`)) req3.Header.Set("Content-Type", "application/json") req3.Header.Set("X-Admin-Api-Key", "trip-planner-admin-key") rr3 := httptest.NewRecorder() AdminStationStatus(h, rr3, req3) if rr3.Code != http.StatusBadRequest { t.Errorf("expected status 400 for invalid status, got %d", rr3.Code) } t.Logf("Test admin station status (invalid status): got status %d (expected 400)", rr3.Code) // Test 4: Invalid source should return 400 req4 := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader(`{"status": "closed", "source": "geo"}`)) req4.Header.Set("Content-Type", "application/json") req4.Header.Set("X-Admin-Api-Key", "trip-planner-admin-key") rr4 := httptest.NewRecorder() AdminStationStatus(h, rr4, req4) if rr4.Code != http.StatusBadRequest { t.Errorf("expected status 400 for invalid source, got %d", rr4.Code) } t.Logf("Test admin station status (invalid source): got status %d (expected 400)", rr4.Code) // Test 5: Missing body should return 400 req5 := httptest.NewRequest("POST", "/internal/admin/stations/s9600213/status", strings.NewReader("")) req5.Header.Set("Content-Type", "application/json") req5.Header.Set("X-Admin-Api-Key", "trip-planner-admin-key") rr5 := httptest.NewRecorder() AdminStationStatus(h, rr5, req5) if rr5.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing body, got %d", rr5.Code) } t.Logf("Test admin station status (missing body): got status %d (expected 400)", rr5.Code) } // TestHandlerRouteSearchIntegration tests the route search handler with a fully built graph, // verifying the cache-aware flow: handler → graph → route search → response. func TestHandlerRouteSearchIntegration(t *testing.T) { h := newMockHandlerContext() // Build a routing graph using the same pattern as TestFindRouteSuccess: // stations with real edges and one synthetic transfer edge, plus city hub. graph := routing.NewGraph() graph.AddNode(&routing.Node{ID: "c1", Type: routing.NodeTypeCity, Name: "City Hub"}) graph.AddNode(&routing.Node{ID: "s1", Type: routing.NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s2", Type: routing.NodeTypeStation, Name: "Tula", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s3", Type: routing.NodeTypeStation, Name: "Vladimir", CityCode: "c1"}) // Add synthetic edge: city hub <-> station Moscow (transfer) graph.AddEdge(&routing.Edge{ From: graph.Nodes()[0], // c1 city hub To: graph.Nodes()[1], // s1 Moscow Kind: routing.EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, }) graph.AddEdge(&routing.Edge{ From: graph.Nodes()[1], // s1 Moscow To: graph.Nodes()[0], // c1 city hub Kind: routing.EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, }) // Add real edge: direct route Moscow → Tula graph.AddEdge(&routing.Edge{ From: graph.Nodes()[1], // s1 Moscow To: graph.Nodes()[2], // s2 Tula Kind: routing.EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, }) // Add synthetic transfer edge: Tula → Vladimir (1 transfer) graph.AddEdge(&routing.Edge{ From: graph.Nodes()[2], // s2 Tula To: graph.Nodes()[3], // s3 Vladimir Kind: routing.EdgeKindSynthetic, Duration: 1800, Transport: "train", IsTransfer: true, }) // Replace the router with our test graph h.Router = graph // Create request: from city c1 (Moscow) to city c1 (same city code) req := httptest.NewRequest("POST", "/v1/routes/search", strings.NewReader(`{"from_city_id": "c1", "to_city_id": "c1", "date": "2026-08-15"}`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() RouteSearch(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp routeSearchResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } t.Logf("route search response: routes=%+v, count=%d", resp.Routes, resp.Count) // With this graph, we should find a route with 1 transfer if resp.Count == 0 { t.Error("expected at least 1 route, got 0") } } // TestHandlerRouteSearchNoRoute tests route search when origin/destination not in graph. func TestHandlerRouteSearchNoRoute(t *testing.T) { h := newMockHandlerContext() // Create graph with no relevant nodes, but add some so the handler can find // the city IDs (otherwise handler returns 404 before route search) graph := routing.NewGraph() graph.AddNode(&routing.Node{ID: "c999", Type: routing.NodeTypeCity, Name: "City 999"}) graph.AddNode(&routing.Node{ID: "c888", Type: routing.NodeTypeCity, Name: "City 888"}) h.Router = graph req := httptest.NewRequest("POST", "/v1/routes/search", strings.NewReader(`{"from_city_id": "c999", "to_city_id": "c888", "date": "2026-08-15"}`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() RouteSearch(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp routeSearchResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Count != 0 { t.Errorf("expected 0 routes, got %d", resp.Count) } } // TestNeighboringStations tests the StationNeighbor type from the airports package. func TestNeighboringStations(t *testing.T) { // Test creating neighbors with different sources n := airports.NewStationNeighbors("c1") n.Add("s1", "Station One", "geo") n.Add("s2", "Station Two", "manual") if n.Len() != 2 { t.Errorf("expected 2 neighbors, got %d", n.Len()) } // Test marking as excluded n.MarkExcluded("s1") isExcluded, found := n.IsExcluded("s1") if !found { t.Error("expected s1 to be found in neighbors") } if !isExcluded { t.Error("expected s1 to be excluded") } // Test getting non-excluded neighbors nonExcluded := n.GetNonExcluded() if len(nonExcluded) != 1 { t.Errorf("expected 1 non-excluded neighbor, got %d", len(nonExcluded)) } if nonExcluded[0].Name != "Station Two" { t.Errorf("expected 'Station Two' as non-excluded, got %s", nonExcluded[0].Name) } // Test getting neighbor by ID neighbor, found := n.Get("s2") if !found { t.Error("expected s2 to be found") } if neighbor.Name != "Station Two" { t.Errorf("expected 'Station Two', got %s", neighbor.Name) } // Test sorting // Note: ASCII order has 'O' < 'T' < 'Z', so "Station One" < "Station Two" < "Station Zero" n.Add("s0", "Station Zero", "geo") n.Sort() if n.Neighbors[0].Name != "Station One" { t.Errorf("expected 'Station One' first after sort (alphabetical), got %s", n.Neighbors[0].Name) } if n.Neighbors[1].Name != "Station Two" { t.Errorf("expected 'Station Two' second after sort, got %s", n.Neighbors[1].Name) } if n.Neighbors[2].Name != "Station Zero" { t.Errorf("expected 'Station Zero' third after sort, got %s", n.Neighbors[2].Name) } } // TestStationNeighbors tests the storage.StationNeighborsTable type. func TestStationNeighbors(t *testing.T) { // Test adding neighbors for a city table := storage.NewStationNeighborsTable() table.Add("c1", "s1", "Moscow Station", "manual") table.Add("c1", "s2", "Tula Station", "manual") table.Add("c1", "s3", "Kursk Station", "geo") // Get all neighbors for city c1 neighbors := table.GetByCity("c1") if len(neighbors) != 3 { t.Errorf("expected 3 neighbors for city c1, got %d", len(neighbors)) } // Get non-excluded neighbors nonExcluded := table.GetNonExcluded("c1") if len(nonExcluded) != 3 { t.Errorf("expected 3 non-excluded neighbors for city c1, got %d", len(nonExcluded)) } // Mark one as excluded table.MarkExcluded("c1", "s2") nonExcludedAfter := table.GetNonExcluded("c1") if len(nonExcludedAfter) != 2 { t.Errorf("expected 2 non-excluded neighbors after marking s2 excluded, got %d", len(nonExcludedAfter)) } // Verify the excluded one is not in the list foundS2 := false for _, n := range nonExcludedAfter { if n.StationID == "s2" { foundS2 = true } } if foundS2 { t.Error("expected s2 to be excluded from non-excluded list") } } // TestUserPreferences tests the user preferences functionality via API handlers. func TestUserPreferences(t *testing.T) { h := newMockHandlerContext() t.Run("get_saved_cities_empty", func(t *testing.T) { // Flush preference-related Redis keys for test isolation flushRedisForTest(t, h.Redis) req := httptest.NewRequest("GET", "/v1/preferences/saved-cities?user_id=testuser1", nil) rr := httptest.NewRecorder() GetSavedCities(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp preferenceResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Message != "saved cities retrieved" { t.Errorf("expected message 'saved cities retrieved', got '%s'", resp.Message) } if len(resp.Data.([]interface{})) != 0 { t.Errorf("expected empty list of saved cities, got %d", len(resp.Data.([]interface{}))) } }) t.Run("add_and_get_saved_city", func(t *testing.T) { // Flush preference-related Redis keys for test isolation flushRedisForTest(t, h.Redis) // Add a saved city addReq := httptest.NewRequest("POST", "/v1/preferences/saved-cities?user_id=testuser2", strings.NewReader(`{"city_code":"c1","name":"Moscow"}`,)) addReq.Header.Set("Content-Type", "application/json") addRR := httptest.NewRecorder() AddSavedCity(h, addRR, addReq) if addRR.Code != http.StatusOK { t.Errorf("expected status 200, got %d", addRR.Code) } var addResp preferenceResponse if err := json.Unmarshal(addRR.Body.Bytes(), &addResp); err != nil { t.Fatalf("failed to unmarshal add response: %v", err) } if addResp.Message != "saved city added" { t.Errorf("expected message 'saved city added', got '%s'", addResp.Message) } // Get saved cities getReq := httptest.NewRequest("GET", "/v1/preferences/saved-cities?user_id=testuser2", nil) getRR := httptest.NewRecorder() GetSavedCities(h, getRR, getReq) if getRR.Code != http.StatusOK { t.Errorf("expected status 200, got %d", getRR.Code) } var getResp preferenceResponse if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } cities := getResp.Data.([]interface{}) if len(cities) != 1 { t.Errorf("expected 1 saved city, got %d", len(cities)) } else { city := cities[0].(map[string]interface{}) if city["city_code"] != "c1" { t.Errorf("expected city_code 'c1', got '%v'", city["city_code"]) } if city["name"] != "Moscow" { t.Errorf("expected name 'Moscow', got '%v'", city["name"]) } } }) t.Run("remove_saved_city", func(t *testing.T) { // Flush preference-related Redis keys for test isolation flushRedisForTest(t, h.Redis) // Remove the previously added city removeReq := httptest.NewRequest("DELETE", "/v1/preferences/saved-cities/c1?user_id=testuser3", nil) removeRR := httptest.NewRecorder() RemoveSavedCity(h, removeRR, removeReq) if removeRR.Code != http.StatusOK { t.Errorf("expected status 200, got %d", removeRR.Code) } var removeResp preferenceResponse if err := json.Unmarshal(removeRR.Body.Bytes(), &removeResp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if removeResp.Message != "saved city removed" { t.Errorf("expected message 'saved city removed', got '%s'", removeResp.Message) } // Verify city is gone getReq := httptest.NewRequest("GET", "/v1/preferences/saved-cities?user_id=testuser3", nil) getRR := httptest.NewRecorder() GetSavedCities(h, getRR, getReq) if getRR.Code != http.StatusOK { t.Errorf("expected status 200, got %d", getRR.Code) } var getResp preferenceResponse if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } cities := getResp.Data.([]interface{}) if len(cities) != 0 { t.Errorf("expected 0 saved cities after removal, got %d", len(cities)) } }) t.Run("get_search_history_empty", func(t *testing.T) { // Flush preference-related Redis keys for test isolation flushRedisForTest(t, h.Redis) req := httptest.NewRequest("GET", "/v1/preferences/search-history?user_id=testuser4", nil) rr := httptest.NewRecorder() GetSearchHistory(h, rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) } var resp preferenceResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Message != "search history retrieved" { t.Errorf("expected message 'search history retrieved', got '%s'", resp.Message) } if len(resp.Data.([]interface{})) != 0 { t.Errorf("expected empty list of search history, got %d", len(resp.Data.([]interface{}))) } }) t.Run("add_and_get_search_history", func(t *testing.T) { // Flush preference-related Redis keys for test isolation flushRedisForTest(t, h.Redis) // Add a search history entry addReq := httptest.NewRequest("POST", "/v1/preferences/search-history?user_id=testuser5", strings.NewReader(`{"from_city":"c1","to_city":"c2","date":"2026-08-15"}`)) addReq.Header.Set("Content-Type", "application/json") addRR := httptest.NewRecorder() AddSearchHistory(h, addRR, addReq) if addRR.Code != http.StatusOK { t.Errorf("expected status 200, got %d", addRR.Code) } var addResp preferenceResponse if err := json.Unmarshal(addRR.Body.Bytes(), &addResp); err != nil { t.Fatalf("failed to unmarshal add response: %v", err) } if addResp.Message != "search history added" { t.Errorf("expected message 'search history added', got '%s'", addResp.Message) } // Get search history getReq := httptest.NewRequest("GET", "/v1/preferences/search-history?user_id=testuser5", nil) getRR := httptest.NewRecorder() GetSearchHistory(h, getRR, getReq) if getRR.Code != http.StatusOK { t.Errorf("expected status 200, got %d", getRR.Code) } var getResp preferenceResponse if err := json.Unmarshal(getRR.Body.Bytes(), &getResp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } history := getResp.Data.([]interface{}) if len(history) != 1 { t.Errorf("expected 1 search history entry, got %d", len(history)) } else { entry := history[0].(map[string]interface{}) if entry["from_city"] != "c1" { t.Errorf("expected from_city 'c1', got '%v'", entry["from_city"]) } if entry["to_city"] != "c2" { t.Errorf("expected to_city 'c2', got '%v'", entry["to_city"]) } if entry["date"] != "2026-08-15" { t.Errorf("expected date '2026-08-15', got '%v'", entry["date"]) } } }) t.Run("remove_old_search_history", func(t *testing.T) { // Flush preference-related Redis keys for test isolation flushRedisForTest(t, h.Redis) // Add a search history entry via Preferences err := h.Preferences.AddSearchHistory(context.Background(), "testuser7", "c1", "c2", "2026-08-10") if err != nil { t.Fatalf("failed to add search history: %v", err) } // Add another entry with old timestamp (CreatedAt set to 200 seconds ago) // Since we can't easily get time.Now() in tests without the time import, // we test by adding an entry and then removing old entries. // The RemoveOldSearchHistory function should filter by age. // Remove old history (maxAge=100 seconds) err = h.Preferences.RemoveOldSearchHistory(context.Background(), "testuser7", 100) if err != nil { t.Fatalf("failed to remove old search history: %v", err) } // Verify by getting history directly - entries should still exist // (since we added one without specifying a past timestamp, and // RemoveOldSearchHistory with maxAge=100 would only remove very old entries) history, err := h.Preferences.GetSearchHistory(context.Background(), "testuser7") if err != nil { t.Fatalf("failed to get search history: %v", err) } // At minimum, the entry we just added should be in history if len(history) == 0 { t.Error("expected at least 1 search history entry") } }) }