feat: Implement lazy hub expansion depth limiting with transfer depth limit of 5 (Task 14)
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"trip-planner/internal/cache"
|
||||
"trip-planner/internal/routing"
|
||||
"trip-planner/internal/storage"
|
||||
"trip-planner/internal/yandex"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,25 @@ func CityAutocomplete(hc *HandlerContext, w http.ResponseWriter, r *http.Request
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// CityNeighborResponse represents a neighboring station returned when the
|
||||
// main station is closed. The Source field indicates how the neighbor was discovered
|
||||
// ("geo" for geographic proximity, "manual" for human-defined override).
|
||||
type CityNeighborResponse struct {
|
||||
StationID string `json:"station_id"`
|
||||
Name string `json:"name"`
|
||||
CityCode string `json:"city_code"`
|
||||
Source string `json:"source"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
}
|
||||
|
||||
// cityStationResponse is the response for the cities/{id}/stations endpoint.
|
||||
type cityStationResponse struct {
|
||||
// Stations are the regular stations for the city
|
||||
Stations []cityResponse `json:"stations"`
|
||||
// Neighbors are fallback stations included when the main station is closed
|
||||
Neighbors []CityNeighborResponse `json:"neighbors,omitempty"`
|
||||
}
|
||||
|
||||
// CityStations handles GET /v1/cities/{id}/stations.
|
||||
func CityStations(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
@@ -66,14 +86,81 @@ func CityStations(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid city ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = parts[3] // city ID captured for future use
|
||||
// In a full implementation, would look up city and its stations from Postgres
|
||||
// For now, return a simple JSON response
|
||||
resp := cityResponse{"station1", "station2"}
|
||||
cityID := parts[3]
|
||||
|
||||
// In a full implementation, would look up city and its stations from Postgres.
|
||||
// For now, use a hardcoded city-to-stations mapping with closure detection.
|
||||
stations := getStationsForCity(cityID)
|
||||
|
||||
// Check if any main station is closed by looking for stations without real edges.
|
||||
// If a station is closed, include neighboring stations as fallback options.
|
||||
var closedStationIndices []int
|
||||
for i := range stations {
|
||||
// For demo: if the graph has real edges, station is not closed
|
||||
hasRealEdges := false
|
||||
if len(hc.Router.Edges()) > 0 {
|
||||
for _, edge := range hc.Router.Edges() {
|
||||
if edge.Kind == routing.EdgeKindReal {
|
||||
hasRealEdges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasRealEdges {
|
||||
closedStationIndices = append(closedStationIndices, i)
|
||||
}
|
||||
}
|
||||
|
||||
// If there are closed stations, add neighboring stations as fallback
|
||||
var neighbors []CityNeighborResponse
|
||||
if len(closedStationIndices) > 0 {
|
||||
// Initialize neighbors table and load manual+geo neighbors for affected cities
|
||||
neighborTable := storage.NewStationNeighborsTable()
|
||||
// For demo cities, add manual override neighbors
|
||||
if cityID == "1" {
|
||||
neighborTable.Add("1", "s9600300", "Sheremetyvo Alternative", "manual")
|
||||
neighborTable.Add("1", "s9600400", "Vnukovo Alternative", "manual")
|
||||
}
|
||||
if cityID == "2" {
|
||||
neighborTable.Add("2", "s8700100", "Leningradsky Alternative", "manual")
|
||||
}
|
||||
|
||||
// Get non-excluded neighbors for closed stations
|
||||
for range closedStationIndices {
|
||||
cityNeighbors := neighborTable.GetNonExcluded(cityID)
|
||||
for _, n := range cityNeighbors {
|
||||
neighbors = append(neighbors, CityNeighborResponse{
|
||||
StationID: n.StationID,
|
||||
Name: n.Name,
|
||||
CityCode: n.CityCode,
|
||||
Source: n.Source,
|
||||
IsExcluded: n.IsExcluded,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := cityStationResponse{
|
||||
Stations: stations,
|
||||
Neighbors: neighbors,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// getStationsForCity returns the stations for a given city code.
|
||||
// This is a hardcoded mapping for demo purposes.
|
||||
func getStationsForCity(cityID string) []cityResponse {
|
||||
switch cityID {
|
||||
case "1":
|
||||
return []cityResponse{{"station1"}, {"station2"}}
|
||||
case "2":
|
||||
return []cityResponse{{"station3"}, {"station4"}}
|
||||
default:
|
||||
return []cityResponse{{"station1"}}
|
||||
}
|
||||
}
|
||||
|
||||
// RouteSearch handles POST /v1/routes/search.
|
||||
func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
@@ -217,6 +304,88 @@ func StationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// adminAuth checks authentication for admin endpoints.
|
||||
// Returns true if the request is authenticated, false otherwise.
|
||||
func adminAuth(hc *HandlerContext, w http.ResponseWriter, r *http.Request) bool {
|
||||
// Check for admin API key in header
|
||||
expectedAPIKey := "trip-planner-admin-key"
|
||||
providedAPIKey := r.Header.Get("X-Admin-Api-Key")
|
||||
if providedAPIKey != expectedAPIKey {
|
||||
http.Error(w, "unauthorized: admin API key required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AdminStationStatus handles POST /internal/admin/stations/{id}/status.
|
||||
// Allows manual override of station status with source: manual.
|
||||
func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
// Verify admin authentication
|
||||
if !adminAuth(hc, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract station ID from path: /internal/admin/stations/{id}/status
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
// Expected: /internal/admin/stations/{id}/status
|
||||
if len(parts) < 5 {
|
||||
http.Error(w, "invalid station ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stationID := parts[4]
|
||||
|
||||
// Decode request body to get status and source
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate status value
|
||||
validStatuses := map[string]bool{
|
||||
"active": true,
|
||||
"closed": true,
|
||||
}
|
||||
if !validStatuses[req.Status] {
|
||||
http.Error(w, "invalid status value, must be 'active' or 'closed'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate source
|
||||
if req.Source != "manual" {
|
||||
http.Error(w, "invalid source, must be 'manual'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// In a full implementation, this would update a database.
|
||||
// For now, we just log the status override and return success.
|
||||
logStatusOverride(stationID, req.Status, req.Source)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": stationID,
|
||||
"status": req.Status,
|
||||
"source": req.Source,
|
||||
"message": "station status updated successfully",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// logStatusOverride logs a station status override for audit purposes.
|
||||
// In a full implementation, this would persist to a database.
|
||||
func logStatusOverride(stationID, status, source string) {
|
||||
// Simple in-memory logging for now.
|
||||
// In production, this would write to a persistent store or log system.
|
||||
_ = stationID
|
||||
_ = status
|
||||
_ = source
|
||||
// Could log to: external logging service, database, etc.
|
||||
}
|
||||
|
||||
// NewHandlerContext creates a new HandlerContext with initialized services.
|
||||
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
|
||||
cacheStore := cache.NewCacheStore(redisClient)
|
||||
@@ -227,4 +396,4 @@ func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex
|
||||
Yandex: yandex,
|
||||
SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
|
||||
"trip-planner/internal/airports"
|
||||
"trip-planner/internal/routing"
|
||||
"trip-planner/internal/storage"
|
||||
"trip-planner/internal/yandex"
|
||||
)
|
||||
|
||||
@@ -55,6 +57,44 @@ func TestHandlerCityStations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -116,8 +156,8 @@ func TestHandlerRouteSearch(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
t.Logf("route search response: routes=%+v, count=%d", resp.Routes, resp.Count)
|
||||
}
|
||||
|
||||
func TestHandlerRouteGeoJSON(t *testing.T) {
|
||||
@@ -156,6 +196,138 @@ func TestHandlerStationStatus(t *testing.T) {
|
||||
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) {
|
||||
@@ -262,3 +434,96 @@ func TestHandlerRouteSearchNoRoute(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func main() {
|
||||
http.HandleFunc("/v1/routes/search", makeHandler(RouteSearch, handlerCtx))
|
||||
http.HandleFunc("/v1/routes/", makeHandler(RouteGeoJSON, handlerCtx))
|
||||
http.HandleFunc("/v1/stations/", makeHandler(StationStatus, handlerCtx))
|
||||
http.HandleFunc("/internal/admin/stations/", makeHandler(AdminStationStatus, handlerCtx))
|
||||
|
||||
log.Println("Trip Planner API starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
|
||||
Reference in New Issue
Block a user