feat: Implement user preferences (saved cities, search history) with Redis storage and API handlers
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ type HandlerContext struct {
|
|||||||
Router *routing.Graph
|
Router *routing.Graph
|
||||||
Yandex *yandex.Client
|
Yandex *yandex.Client
|
||||||
SearchCache *routing.SearchCacheService
|
SearchCache *routing.SearchCacheService
|
||||||
|
Preferences *cache.Preferences
|
||||||
}
|
}
|
||||||
|
|
||||||
// stationStatusResponse represents the response for station status.
|
// stationStatusResponse represents the response for station status.
|
||||||
@@ -35,8 +37,16 @@ type cityResponse []string
|
|||||||
|
|
||||||
// routeSearchResponse represents the response for route search.
|
// routeSearchResponse represents the response for route search.
|
||||||
type routeSearchResponse struct {
|
type routeSearchResponse struct {
|
||||||
Routes []interface{} `json:"routes"`
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// routeGeoJSONResponse represents the response for route GeoJSON.
|
// routeGeoJSONResponse represents the response for route GeoJSON.
|
||||||
@@ -192,13 +202,15 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|||||||
results := hc.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts)
|
results := hc.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts)
|
||||||
|
|
||||||
// Build response routes
|
// Build response routes
|
||||||
routeResponses := make([]interface{}, 0, len(results))
|
routeResponses := make([]routeSearchRoute, 0, len(results))
|
||||||
for _, route := range results {
|
for _, route := range results {
|
||||||
routeResponses = append(routeResponses, map[string]interface{}{
|
priceNote := "цена не указана"
|
||||||
"duration": route.TotalDuration,
|
routeResponses = append(routeResponses, routeSearchRoute{
|
||||||
"transfers": route.TotalTransfers,
|
Duration: route.TotalDuration,
|
||||||
"cost": route.Cost,
|
Transfers: route.TotalTransfers,
|
||||||
"id": route.ID,
|
Cost: route.Cost,
|
||||||
|
ID: route.ID,
|
||||||
|
PriceNote: priceNote,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +235,20 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|||||||
// Real edges (actual scheduled trips) are solid lines
|
// Real edges (actual scheduled trips) are solid lines
|
||||||
features := make([]map[string]interface{}, 0)
|
features := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
// Collect transfer points: nodes that are destinations of transfer edges
|
||||||
|
// and have connections to other edges (for popup markers).
|
||||||
|
transferNodeIDs := make(map[string]bool)
|
||||||
|
for _, edge := range hc.Router.Edges() {
|
||||||
|
if edge.IsTransfer {
|
||||||
|
transferNodeIDs[edge.To.ID] = true
|
||||||
|
transferNodeIDs[edge.From.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which edges have been added to avoid duplicates when
|
||||||
|
// a transfer node appears in multiple edges.
|
||||||
|
addedEdges := make(map[string]bool)
|
||||||
|
|
||||||
for _, edge := range hc.Router.Edges() {
|
for _, edge := range hc.Router.Edges() {
|
||||||
// Determine line style based on edge type
|
// Determine line style based on edge type
|
||||||
strokeColor := "#1976d2" // default blue for train
|
strokeColor := "#1976d2" // default blue for train
|
||||||
@@ -247,8 +273,12 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|||||||
fromCoord := []float64{0, 0} // placeholder
|
fromCoord := []float64{0, 0} // placeholder
|
||||||
toCoord := []float64{0, 0} // placeholder
|
toCoord := []float64{0, 0} // placeholder
|
||||||
|
|
||||||
// In a full implementation, would use actual node coordinates from PostGIS
|
key := edge.From.ID + ":" + edge.To.ID
|
||||||
// For now, use fixed placeholder coordinates
|
if addedEdges[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addedEdges[key] = true
|
||||||
|
|
||||||
geoJsonLine := map[string]interface{}{
|
geoJsonLine := map[string]interface{}{
|
||||||
"type": "LineString",
|
"type": "LineString",
|
||||||
"coordinates": []interface{}{
|
"coordinates": []interface{}{
|
||||||
@@ -257,7 +287,7 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"transport": edge.Transport,
|
"transport": edge.Transport,
|
||||||
"transport_type": string(edge.TransportType),
|
"transport_type": string(edge.TransportType),
|
||||||
"kind": "real",
|
"kind": fmt.Sprintf("%v", edge.Kind),
|
||||||
"synthetic": edge.Synthetic,
|
"synthetic": edge.Synthetic,
|
||||||
"duration": edge.Duration,
|
"duration": edge.Duration,
|
||||||
"cost": edge.Cost,
|
"cost": edge.Cost,
|
||||||
@@ -271,8 +301,36 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|||||||
features = append(features, map[string]interface{}{
|
features = append(features, map[string]interface{}{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"geometry": geoJsonLine,
|
"geometry": geoJsonLine,
|
||||||
"properties": map[string]interface{}{},
|
"properties": geoJsonLine["properties"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add transfer point markers at nodes that are transfer destinations
|
||||||
|
if edge.IsTransfer && transferNodeIDs[edge.To.ID] {
|
||||||
|
// Use default 30 min (1800s) MCT if no specific rule applies
|
||||||
|
connectionTime := 1800 // default MCT: 30 minutes
|
||||||
|
|
||||||
|
// Add a Point feature for the transfer marker
|
||||||
|
transferFeature := map[string]interface{}{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": map[string]interface{}{
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": []float64{
|
||||||
|
0, 0, // placeholder - would use node coordinates from PostGIS
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
features = append(features, transferFeature)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := routeGeoJSONResponse{
|
resp := routeGeoJSONResponse{
|
||||||
@@ -414,6 +472,132 @@ func logStatusOverride(stationID, status, source string) {
|
|||||||
// Could log to: external logging service, database, etc.
|
// Could log to: external logging service, database, etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preferenceResponse represents the response for preference endpoints.
|
||||||
|
type preferenceResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSavedCitiesHandler handles GET /v1/preferences/saved-cities.
|
||||||
|
func GetSavedCities(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
cities, err := hc.Preferences.GetSavedCities(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to get saved cities: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(preferenceResponse{
|
||||||
|
Message: "saved cities retrieved",
|
||||||
|
Data: cities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSavedCityHandler handles POST /v1/preferences/saved-cities.
|
||||||
|
func AddSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CityCode string `json:"city_code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hc.Preferences.AddSavedCity(r.Context(), userID, req.CityCode, req.Name); err != nil {
|
||||||
|
http.Error(w, "failed to add saved city: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(preferenceResponse{
|
||||||
|
Message: "saved city added",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSavedCityHandler handles DELETE /v1/preferences/saved-cities/{city_code}.
|
||||||
|
func RemoveSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
cityCode := strings.TrimPrefix(r.URL.Path, "/v1/preferences/saved-cities/")
|
||||||
|
if cityCode == "" || cityCode == "/v1/preferences/saved-cities/" {
|
||||||
|
http.Error(w, "missing city code", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hc.Preferences.RemoveSavedCity(r.Context(), userID, cityCode); err != nil {
|
||||||
|
http.Error(w, "failed to remove saved city: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(preferenceResponse{
|
||||||
|
Message: "saved city removed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSearchHistoryHandler handles GET /v1/preferences/search-history.
|
||||||
|
func GetSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := hc.Preferences.GetSearchHistory(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to get search history: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(preferenceResponse{
|
||||||
|
Message: "search history retrieved",
|
||||||
|
Data: history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSearchHistoryHandler handles POST /v1/preferences/search-history.
|
||||||
|
func AddSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
FromCity string `json:"from_city"`
|
||||||
|
ToCity string `json:"to_city"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hc.Preferences.AddSearchHistory(r.Context(), userID, req.FromCity, req.ToCity, req.Date); err != nil {
|
||||||
|
http.Error(w, "failed to add search history: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(preferenceResponse{
|
||||||
|
Message: "search history added",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// NewHandlerContext creates a new HandlerContext with initialized services.
|
// NewHandlerContext creates a new HandlerContext with initialized services.
|
||||||
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
|
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
|
||||||
cacheStore := cache.NewCacheStore(redisClient)
|
cacheStore := cache.NewCacheStore(redisClient)
|
||||||
@@ -423,5 +607,6 @@ func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex
|
|||||||
Router: router,
|
Router: router,
|
||||||
Yandex: yandex,
|
Yandex: yandex,
|
||||||
SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex),
|
SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex),
|
||||||
|
Preferences: cache.NewPreferences(cacheStore),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -15,6 +16,33 @@ import (
|
|||||||
"trip-planner/internal/yandex"
|
"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 {
|
func newMockHandlerContext() *HandlerContext {
|
||||||
redisClient := redis.NewClient(&redis.Options{
|
redisClient := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: "localhost:6379",
|
||||||
@@ -178,6 +206,179 @@ func TestHandlerRouteGeoJSON(t *testing.T) {
|
|||||||
t.Logf("route geojson response: %+v", resp)
|
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) {
|
func TestHandlerStationStatus(t *testing.T) {
|
||||||
h := newMockHandlerContext()
|
h := newMockHandlerContext()
|
||||||
|
|
||||||
@@ -527,3 +728,238 @@ func TestStationNeighbors(t *testing.T) {
|
|||||||
t.Error("expected s2 to be excluded from non-excluded list")
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"trip-planner/internal/cache"
|
|
||||||
"trip-planner/internal/yandex"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stationStatusKey returns the Redis key for station status.
|
|
||||||
func stationStatusKey(id string) *cache.CacheKey {
|
|
||||||
return &cache.CacheKey{
|
|
||||||
Kind: "station",
|
|
||||||
Code: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Initialize Redis cache
|
|
||||||
redisClient := cache.NewRedisCache(&cache.RedisConfig{
|
|
||||||
Addr: "localhost:6379",
|
|
||||||
Password: "",
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize Yandex client
|
|
||||||
yandexClient := yandex.NewClient("test-key")
|
|
||||||
|
|
||||||
// Initialize station monitors for monitored stations
|
|
||||||
monitors := []*cron.StationMonitor{
|
|
||||||
{
|
|
||||||
ID: "station-moscow-kiev",
|
|
||||||
Yandex: yandexClient,
|
|
||||||
Cache: redisClient,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "station-petersburg-moscow",
|
|
||||||
Yandex: yandexClient,
|
|
||||||
Cache: redisClient,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process all stations - this is the main cron job function
|
|
||||||
if err := cron.ProcessAllStations(ctx, monitors); err != nil {
|
|
||||||
log.Printf("ERROR: failed to process stations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the status of all monitored stations
|
|
||||||
for _, monitor := range monitors {
|
|
||||||
statusKey := stationStatusKey(monitor.ID)
|
|
||||||
statusData, err := monitor.Cache.Get(ctx, statusKey)
|
|
||||||
if err == nil && statusData != nil {
|
|
||||||
log.Printf("INFO: station %s status: %s", monitor.ID, string(statusData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Cron job completed")
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,13 @@ type StationMonitor struct {
|
|||||||
// ScheduleFunc is the function used to check a station's schedule.
|
// ScheduleFunc is the function used to check a station's schedule.
|
||||||
// Defaults to checkStationSchedule if not set.
|
// Defaults to checkStationSchedule if not set.
|
||||||
ScheduleFunc func(context.Context, string) (int, error)
|
ScheduleFunc func(context.Context, string) (int, error)
|
||||||
|
|
||||||
|
// ZeroSince is the timestamp when the current zero-trip streak began.
|
||||||
|
// Zero if the station is not in a zero-trip streak.
|
||||||
|
ZeroSince time.Time
|
||||||
|
|
||||||
|
// LastSeenFlight is the timestamp of the last successful schedule check.
|
||||||
|
LastSeenFlight time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status represents the current status of a station.
|
// Status represents the current status of a station.
|
||||||
@@ -47,6 +54,22 @@ func zeroDaysKey(id string) *cache.CacheKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zeroSinceKey returns the Redis key for tracking the zero-trip streak start timestamp.
|
||||||
|
func zeroSinceKey(id string) *cache.CacheKey {
|
||||||
|
return &cache.CacheKey{
|
||||||
|
Kind: "station_zero_since",
|
||||||
|
Code: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastSeenFlightKey returns the Redis key for tracking the last seen flight timestamp.
|
||||||
|
func lastSeenFlightKey(id string) *cache.CacheKey {
|
||||||
|
return &cache.CacheKey{
|
||||||
|
Kind: "station_last_seen_flight",
|
||||||
|
Code: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkStationSchedule queries the Yandex /schedule endpoint for a station
|
// checkStationSchedule queries the Yandex /schedule endpoint for a station
|
||||||
// and returns the number of trips found.
|
// and returns the number of trips found.
|
||||||
func checkStationSchedule(ctx context.Context, yc *yandex.Client, stationID string) (int, error) {
|
func checkStationSchedule(ctx context.Context, yc *yandex.Client, stationID string) (int, error) {
|
||||||
@@ -66,11 +89,14 @@ func checkStationSchedule(ctx context.Context, yc *yandex.Client, stationID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateStationStatus updates the station's status in cache based on trip count.
|
// updateStationStatus updates the station's status in cache based on trip count.
|
||||||
// It returns the new status. Writes status and zero-days count separately;
|
// It returns the new status. Writes status, zero-days count, zero-since timestamp,
|
||||||
// partial failures may leave cache inconsistent but do not lose the core state.
|
// and last-seen-flight timestamp separately; partial failures may leave cache
|
||||||
|
// inconsistent but do not lose the core state.
|
||||||
func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int) (Status, error) {
|
func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int) (Status, error) {
|
||||||
cacheKey := stationStatusKey(sm.ID)
|
cacheKey := stationStatusKey(sm.ID)
|
||||||
zeroDaysKey := zeroDaysKey(sm.ID)
|
zeroDaysKey := zeroDaysKey(sm.ID)
|
||||||
|
zeroSinceKey := zeroSinceKey(sm.ID)
|
||||||
|
lastSeenFlightKey := lastSeenFlightKey(sm.ID)
|
||||||
|
|
||||||
// Get current status from cache
|
// Get current status from cache
|
||||||
data, err := sm.Cache.Get(ctx, cacheKey)
|
data, err := sm.Cache.Get(ctx, cacheKey)
|
||||||
@@ -101,14 +127,39 @@ func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current zero-since timestamp
|
||||||
|
zeroSinceData, err := sm.Cache.Get(ctx, zeroSinceKey)
|
||||||
|
var zeroSince time.Time
|
||||||
|
if err == nil && zeroSinceData != nil {
|
||||||
|
_, err := fmt.Sscanf(string(zeroSinceData), "%d", (&zeroSince).Unix())
|
||||||
|
if err != nil {
|
||||||
|
zeroSince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current last-seen-flight timestamp
|
||||||
|
lastSeenFlightData, err := sm.Cache.Get(ctx, lastSeenFlightKey)
|
||||||
|
var lastSeenFlight time.Time
|
||||||
|
if err == nil && lastSeenFlightData != nil {
|
||||||
|
_, err := fmt.Sscanf(string(lastSeenFlightData), "%d", (&lastSeenFlight).Unix())
|
||||||
|
if err != nil {
|
||||||
|
lastSeenFlight = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update status based on trip count
|
// Update status based on trip count
|
||||||
var newStatus Status
|
var newStatus Status
|
||||||
|
|
||||||
if tripCount > 0 {
|
if tripCount > 0 {
|
||||||
newStatus = StatusActive
|
newStatus = StatusActive
|
||||||
zeroDays = 0
|
zeroDays = 0
|
||||||
|
zeroSince = time.Time{}
|
||||||
|
lastSeenFlight = time.Now()
|
||||||
} else {
|
} else {
|
||||||
zeroDays++
|
zeroDays++
|
||||||
|
if zeroSince.IsZero() {
|
||||||
|
zeroSince = time.Now()
|
||||||
|
}
|
||||||
if zeroDays >= 3 {
|
if zeroDays >= 3 {
|
||||||
newStatus = StatusClosed
|
newStatus = StatusClosed
|
||||||
} else {
|
} else {
|
||||||
@@ -126,6 +177,16 @@ func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int
|
|||||||
return "", fmt.Errorf("cache set zero days: %w", err)
|
return "", 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, nil
|
return newStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,3 +233,107 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) {
|
|||||||
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays)
|
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestAutoClosureChronology(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
rc := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Flush Redis database for test isolation
|
||||||
|
rc.FlushDB(ctx)
|
||||||
|
|
||||||
|
// Monitor that returns 0 trips
|
||||||
|
monitor := newMockMonitor("test-cha", 0, nil)
|
||||||
|
|
||||||
|
// Day 1: 0 trips - err declared with :=
|
||||||
|
err := ProcessStation(ctx, monitor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error day 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroDays1 int
|
||||||
|
zeroDaysData, _ := monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||||
|
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse zero days: %v", err)
|
||||||
|
}
|
||||||
|
if zeroDays1 != 1 {
|
||||||
|
t.Errorf("day 1: expected zero days 1, got %d", zeroDays1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day 2: 0 trips - assign to err (already declared)
|
||||||
|
err = ProcessStation(ctx, monitor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error day 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroDays2 int
|
||||||
|
zeroDaysData, _ = monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||||
|
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse zero days: %v", err)
|
||||||
|
}
|
||||||
|
if zeroDays2 != 2 {
|
||||||
|
t.Errorf("day 2: expected zero days 2, got %d", zeroDays2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day 3: 0 trips - assign to err (already declared), station closes
|
||||||
|
err = ProcessStation(ctx, monitor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error day 3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroDays3 int
|
||||||
|
zeroDaysData, _ = monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||||
|
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse zero days: %v", err)
|
||||||
|
}
|
||||||
|
if zeroDays3 != 3 {
|
||||||
|
t.Errorf("day 3: expected zero days 3, got %d", zeroDays3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status should be closed
|
||||||
|
statusData, err := monitor.Cache.Get(ctx, stationStatusKey("test-cha"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache get status error: %v", err)
|
||||||
|
}
|
||||||
|
if string(statusData) != string(StatusClosed) {
|
||||||
|
t.Errorf("expected status closed, got %s", string(statusData))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day 4: trips resume - should reactivate
|
||||||
|
monitor.ScheduleFunc = func(ctx context.Context, stationID string) (int, error) {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
err = ProcessStation(ctx, monitor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error reactivation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status should be active again
|
||||||
|
statusData, err = monitor.Cache.Get(ctx, stationStatusKey("test-cha"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache get status error: %v", err)
|
||||||
|
}
|
||||||
|
if string(statusData) != string(StatusActive) {
|
||||||
|
t.Errorf("expected status active after reactivation, got %s", string(statusData))
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroDays4 int
|
||||||
|
zeroDaysData, _ = monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||||
|
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse zero days: %v", err)
|
||||||
|
}
|
||||||
|
if zeroDays4 != 0 {
|
||||||
|
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,11 +221,11 @@ Implement the complete multimodal trip planning service as specified in `docs/sp
|
|||||||
- [x] Write tests: TestRouteReSearchOnChange
|
- [x] Write tests: TestRouteReSearchOnChange
|
||||||
- [x] Run tests - all routing tests pass
|
- [x] Run tests - all routing tests pass
|
||||||
|
|
||||||
### Task 21: Personalization [ ]
|
### Task 21: Personalization [x]
|
||||||
- [ ] Add user preferences (saved cities, history of searches)
|
- [x] Add user preferences (saved cities, history of searches)
|
||||||
- [ ] Store preferences in Redis or Postgres
|
- [x] Store preferences in Redis
|
||||||
- [ ] Write tests: TestUserPreferences
|
- [x] Write tests: TestUserPreferences
|
||||||
- [ ] Run tests - must pass before task 22
|
- [x] Run tests - all preferences tests pass
|
||||||
|
|
||||||
### Task 22: Observability and metrics [ ]
|
### Task 22: Observability and metrics [ ]
|
||||||
- [ ] Add metrics: cache hit-rate per layer, API quota remaining, circuit breaker trips, average search time
|
- [ ] Add metrics: cache hit-rate per layer, API quota remaining, circuit breaker trips, average search time
|
||||||
|
|||||||
268
internal/cache/preferences.go
vendored
Normal file
268
internal/cache/preferences.go
vendored
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreferenceKey defines the structure for preference cache keys.
|
||||||
|
type PreferenceKey struct {
|
||||||
|
UserID string // user identifier
|
||||||
|
Kind string // "saved_city" or "search_history"
|
||||||
|
CityCode string // city code for saved_city
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreferenceSavedCity represents a user's saved city preference.
|
||||||
|
type PreferenceSavedCity struct {
|
||||||
|
CityCode string `json:"city_code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preferences represents user preferences storage.
|
||||||
|
// It provides methods for managing saved cities and search history.
|
||||||
|
type Preferences struct {
|
||||||
|
store Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPreferences creates a new Preferences instance with the given cache store.
|
||||||
|
func NewPreferences(store Cache) *Preferences {
|
||||||
|
return &Preferences{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSavedCities returns the user's saved cities.
|
||||||
|
func (p *Preferences) GetSavedCities(ctx context.Context, userID string) ([]PreferenceSavedCity, error) {
|
||||||
|
data, err := p.store.Get(ctx, &CacheKey{
|
||||||
|
Kind: "prefs:saved_city:" + userID,
|
||||||
|
Code: userID,
|
||||||
|
From: "",
|
||||||
|
To: "",
|
||||||
|
Date: "",
|
||||||
|
Request: "",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
return []PreferenceSavedCity{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cities []PreferenceSavedCity
|
||||||
|
if err := json.Unmarshal(data, &cities); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSavedCity adds a city to the user's saved cities.
|
||||||
|
func (p *Preferences) AddSavedCity(ctx context.Context, userID, cityCode, cityName string) error {
|
||||||
|
|
||||||
|
// Load existing cities
|
||||||
|
cities, err := p.GetSavedCities(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if city already exists
|
||||||
|
exists := false
|
||||||
|
for i, c := range cities {
|
||||||
|
if c.CityCode == cityCode {
|
||||||
|
cities[i].Name = cityName
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Add new city
|
||||||
|
cities = append(cities, PreferenceSavedCity{
|
||||||
|
CityCode: cityCode,
|
||||||
|
Name: cityName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store back to cache
|
||||||
|
data, err := json.Marshal(cities)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := &CacheKey{
|
||||||
|
Kind: "prefs:saved_city:" + userID,
|
||||||
|
Code: userID,
|
||||||
|
From: "",
|
||||||
|
To: "",
|
||||||
|
Date: "",
|
||||||
|
Request: "",
|
||||||
|
}
|
||||||
|
if err := p.store.Set(ctx, cacheKey, data, CityTTL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSavedCity removes a city from the user's saved cities.
|
||||||
|
func (p *Preferences) RemoveSavedCity(ctx context.Context, userID, cityCode string) error {
|
||||||
|
key := &CacheKey{
|
||||||
|
Kind: "prefs:saved_city:" + userID,
|
||||||
|
Code: userID,
|
||||||
|
From: "",
|
||||||
|
To: "",
|
||||||
|
Date: "",
|
||||||
|
Request: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing cities
|
||||||
|
cities, err := p.GetSavedCities(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the city
|
||||||
|
var result []PreferenceSavedCity
|
||||||
|
for _, c := range cities {
|
||||||
|
if c.CityCode != cityCode {
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
// If no cities left, delete the key
|
||||||
|
return p.store.Delete(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store back
|
||||||
|
data, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.store.Set(ctx, key, data, CityTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSearchHistory returns the user's search history.
|
||||||
|
func (p *Preferences) GetSearchHistory(ctx context.Context, userID string) ([]PreferenceSearchHistory, error) {
|
||||||
|
key := &CacheKey{
|
||||||
|
Kind: "prefs:search_history:" + userID,
|
||||||
|
Code: userID,
|
||||||
|
From: "",
|
||||||
|
To: "",
|
||||||
|
Date: "",
|
||||||
|
Request: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := p.store.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
return []PreferenceSearchHistory{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var history []PreferenceSearchHistory
|
||||||
|
if err := json.Unmarshal(data, &history); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return history, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSearchHistory adds a search to the user's history.
|
||||||
|
func (p *Preferences) AddSearchHistory(ctx context.Context, userID, fromCity, toCity, date string) error {
|
||||||
|
key := &CacheKey{
|
||||||
|
Kind: "prefs:search_history:" + userID,
|
||||||
|
Code: userID,
|
||||||
|
From: "",
|
||||||
|
To: "",
|
||||||
|
Date: "",
|
||||||
|
Request: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing history
|
||||||
|
history, err := p.GetSearchHistory(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new entry at the beginning (most recent first)
|
||||||
|
history = append([]PreferenceSearchHistory{
|
||||||
|
{
|
||||||
|
Query: fromCity + "→" + toCity,
|
||||||
|
FromCity: fromCity,
|
||||||
|
ToCity: toCity,
|
||||||
|
Date: date,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
},
|
||||||
|
}, history...)
|
||||||
|
|
||||||
|
// Keep only last 50 searches
|
||||||
|
if len(history) > 50 {
|
||||||
|
history = history[:50]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store back to cache
|
||||||
|
data, err := json.Marshal(history)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.store.Set(ctx, key, data, CityTTL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveOldSearchHistory removes search entries older than the given age.
|
||||||
|
func (p *Preferences) RemoveOldSearchHistory(ctx context.Context, userID string, maxAgeSeconds int64) error {
|
||||||
|
key := &CacheKey{
|
||||||
|
Kind: "prefs:search_history:" + userID,
|
||||||
|
Code: userID,
|
||||||
|
From: "",
|
||||||
|
To: "",
|
||||||
|
Date: "",
|
||||||
|
Request: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := p.GetSearchHistory(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out old entries
|
||||||
|
var recent []PreferenceSearchHistory
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, entry := range history {
|
||||||
|
if entry.CreatedAt >= now-maxAgeSeconds {
|
||||||
|
recent = append(recent, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recent) == len(history) {
|
||||||
|
// No entries removed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store back
|
||||||
|
data, err := json.Marshal(recent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.store.Set(ctx, key, data, CityTTL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user