feat: Implement user preferences (saved cities, search history) with Redis storage and API handlers
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,6 +16,33 @@ import (
|
||||
"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",
|
||||
@@ -178,6 +206,179 @@ func TestHandlerRouteGeoJSON(t *testing.T) {
|
||||
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()
|
||||
|
||||
@@ -526,4 +727,239 @@ func TestStationNeighbors(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user