From a2db6f7e35e879cf0d620546ba187f44bcded1fd Mon Sep 17 00:00:00 2001 From: "Corey N. Runkel" Date: Fri, 27 Mar 2026 13:59:05 -0400 Subject: [PATCH 1/4] Add StopEvent router --- apps/api_web/lib/api_web/router.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api_web/lib/api_web/router.ex b/apps/api_web/lib/api_web/router.ex index 8963943cc..d8be1b907 100644 --- a/apps/api_web/lib/api_web/router.ex +++ b/apps/api_web/lib/api_web/router.ex @@ -100,6 +100,8 @@ defmodule ApiWeb.Router do resources("/live_facilities", LiveFacilityController, only: [:index, :show]) resources("/live-facilities", LiveFacilityController, only: [:index, :show]) resources("/services", ServiceController, only: [:index, :show]) + resources("/stop_events", StopEventController, only: [:index, :show]) + resources("/stop-events", StopEventController, only: [:index, :show]) end scope "/docs/swagger" do From ba96f8bea294d29eb3fec8959d006e6fa4dcb798 Mon Sep 17 00:00:00 2001 From: "Corey N. Runkel" Date: Fri, 27 Mar 2026 13:59:18 -0400 Subject: [PATCH 2/4] Add location to StopEventView --- apps/api_web/lib/api_web/views/stop_event_view.ex | 5 +++++ apps/api_web/test/api_web/views/stop_event_view_test.exs | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/api_web/lib/api_web/views/stop_event_view.ex b/apps/api_web/lib/api_web/views/stop_event_view.ex index 3abe29cca..001cb181c 100644 --- a/apps/api_web/lib/api_web/views/stop_event_view.ex +++ b/apps/api_web/lib/api_web/views/stop_event_view.ex @@ -1,6 +1,11 @@ defmodule ApiWeb.StopEventView do use ApiWeb.Web, :api_view + location(:stop_event_location) + + def stop_event_location(stop_event, conn), + do: stop_event_path(conn, :show, stop_event.id) + attributes([ :start_date, :direction_id, diff --git a/apps/api_web/test/api_web/views/stop_event_view_test.exs b/apps/api_web/test/api_web/views/stop_event_view_test.exs index 85ba7a56b..9c6527dc0 100644 --- a/apps/api_web/test/api_web/views/stop_event_view_test.exs +++ b/apps/api_web/test/api_web/views/stop_event_view_test.exs @@ -232,4 +232,12 @@ defmodule ApiWeb.StopEventViewTest do assert get_in(rendered, ["data", "relationships", "schedule", "data"]) == nil end end + + describe "location" do + test "returns the correct stop event location", %{conn: conn} do + rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, conn: conn) + + assert rendered["data"]["links"]["self"] =~ "/stop_events/trip1-route1-v1-1" + end + end end From 6975229378b36fb7b7f68cf9879ab9165acf52f9 Mon Sep 17 00:00:00 2001 From: "Corey N. Runkel" Date: Fri, 27 Mar 2026 13:59:27 -0400 Subject: [PATCH 3/4] Add StopEventController --- .../controllers/stop_event_controller.ex | 293 ++++++++++ .../stop_event_controller_test.exs | 503 ++++++++++++++++++ 2 files changed, 796 insertions(+) create mode 100644 apps/api_web/lib/api_web/controllers/stop_event_controller.ex create mode 100644 apps/api_web/test/api_web/controllers/stop_event_controller_test.exs diff --git a/apps/api_web/lib/api_web/controllers/stop_event_controller.ex b/apps/api_web/lib/api_web/controllers/stop_event_controller.ex new file mode 100644 index 000000000..34ac4f9f4 --- /dev/null +++ b/apps/api_web/lib/api_web/controllers/stop_event_controller.ex @@ -0,0 +1,293 @@ +defmodule ApiWeb.StopEventController do + @moduledoc """ + Controller for Stop Events. Filterable by: + + * trip + * stop + * route + * vehicle + * direction_id + """ + use ApiWeb.Web, :api_controller + alias State.StopEvent + + @filters ~w(trip stop route vehicle direction_id) + @includes ~w(trip stop route vehicle schedule) + @pagination_opts [:offset, :limit, :order_by] + @description """ + Stop events represent the actual arrival and departure times of vehicles at stops along their trips. + + Stop events are unique to the start_date, trip_id, route_id, vehicle_id, and stop_sequence. + + To return a list of stop events, **provide at least 1 filter parameter**; requests without filters will return an error. + """ + + def state_module, do: State.StopEvent + + swagger_path :index do + get(path("stop_event", :index)) + + description(""" + List of stop events. + + #{@description} + """) + + common_index_parameters(__MODULE__, :stop_event) + + include_parameters() + + parameter( + "filter[trip]", + :query, + :string, + "Filter by trip ID. #{comma_separated_list()}.", + example: "73885810" + ) + + parameter( + "filter[stop]", + :query, + :string, + "Filter by stop ID. #{comma_separated_list()}.", + example: "2231" + ) + + parameter( + "filter[route]", + :query, + :string, + "Filter by route ID. #{comma_separated_list()}.", + example: "64" + ) + + parameter( + "filter[vehicle]", + :query, + :string, + "Filter by vehicle ID. #{comma_separated_list()}.", + example: "y2071" + ) + + filter_param(:direction_id) + + consumes("application/vnd.api+json") + produces("application/vnd.api+json") + response(200, "OK", Schema.ref(:StopEvents)) + response(400, "Bad Request", Schema.ref(:BadRequest)) + response(403, "Forbidden", Schema.ref(:Forbidden)) + response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) + end + + def index_data(conn, params) do + with :ok <- Params.validate_includes(params, @includes, conn), + {:ok, filtered} <- Params.filter_params(params, @filters, conn) do + formatted_filters = format_filters(filtered) + + if map_size(formatted_filters) == 0 do + {:error, :filter_required} + else + formatted_filters + |> StopEvent.filter_by() + |> State.all(pagination_opts(params, conn)) + end + else + {:error, _, _} = error -> error + end + end + + @spec format_filters(%{optional(String.t()) => String.t()}) :: StopEvent.filters() + defp format_filters(filters) do + Enum.reduce(filters, %{}, fn + {"trip", trip_ids}, acc -> + Map.put(acc, :trip_ids, Params.split_on_comma(trip_ids)) + + {"stop", stop_ids}, acc -> + Map.put(acc, :stop_ids, Params.split_on_comma(stop_ids)) + + {"route", route_ids}, acc -> + Map.put(acc, :route_ids, Params.split_on_comma(route_ids)) + + {"vehicle", vehicle_ids}, acc -> + Map.put(acc, :vehicle_ids, Params.split_on_comma(vehicle_ids)) + + {"direction_id", direction_id}, acc -> + Map.put(acc, :direction_id, Params.direction_id(%{"direction_id" => direction_id})) + + _, acc -> + acc + end) + end + + defp pagination_opts(params, conn) do + opts = + params + |> Params.filter_opts(@pagination_opts, conn) + + if is_list(opts) do + Keyword.put_new(opts, :order_by, {:arrived, :desc}) + else + opts + |> Map.to_list() + |> Keyword.put_new(:order_by, {:arrived, :desc}) + end + end + + swagger_path :show do + get(path("stop_event", :show)) + + description(""" + Show a particular stop event by its composite ID. + + #{@description} + """) + + parameter( + :id, + :path, + :string, + "Unique identifier for stop event (trip_id-route_id-vehicle_id-stop_sequence)" + ) + + include_parameters() + + consumes("application/vnd.api+json") + produces("application/vnd.api+json") + + response(200, "OK", Schema.ref(:StopEvent)) + response(403, "Forbidden", Schema.ref(:Forbidden)) + response(404, "Not Found", Schema.ref(:NotFound)) + response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) + end + + def show_data(_conn, %{"id" => id}) do + StopEvent.by_id(id) + end + + defp include_parameters(schema) do + ApiWeb.SwaggerHelpers.include_parameters( + schema, + @includes, + description: """ + | include | Description | + |-|-| + | `trip` | The trip associated with this stop event. | + | `stop` | The stop where the event occurred. | + | `route` | The route associated with this stop event. | + | `vehicle` | The vehicle that served this trip. | + | `schedule` | The scheduled arrival/departure for this stop event. | + + Note that the included entities may appear in past events but no longer in realtime feeds, so included relationships may be empty. + """ + ) + end + + def swagger_definitions do + import PhoenixSwagger.JsonApi, except: [page: 1] + + %{ + StopEventResource: + resource do + description(""" + Actual arrival and departure times of vehicles at stops. + """) + + attributes do + vehicle_id( + :string, + """ + The vehicle ID that served this trip. + """, + example: "y2071" + ) + + start_date( + :string, + """ + The service date of the trip in YYYY-MM-DD format. + """, + example: "2026-02-24", + format: :date + ) + + trip_id( + :string, + """ + The trip ID associated with this stop event. + """, + example: "73885810" + ) + + direction_id( + :integer, + """ + Direction in which the trip is traveling: + - `0` - Travel in one direction (e.g. outbound travel) + - `1` - Travel in the opposite direction (e.g. inbound travel) + """, + enum: [0, 1], + example: 0 + ) + + route_id( + :string, + """ + The route ID associated with this stop event. + """, + example: "64" + ) + + revenue( + :string, + """ + Whether this stop event is for a revenue trip: + - `REVENUE` - A revenue trip + - `NON_REVENUE` - A non-revenue trip + """, + enum: ["REVENUE", "NON_REVENUE"], + example: "REVENUE" + ) + + stop_id( + :string, + """ + The stop ID where the event occurred. + """, + example: "2231" + ) + + stop_sequence( + :integer, + """ + The stop sequence number along the trip. Increases monotonically but values need not be consecutive. + """, + example: 1 + ) + + arrived( + nullable(%Schema{type: :string, format: :"date-time"}, true), + """ + When the vehicle arrived at the stop. Format is ISO8601/RFC 3339. `null` if the first stop on the trip. + """, + example: "2026-03-13T10:30:00-04:00" + ) + + departed( + nullable(%Schema{type: :string, format: :"date-time"}, true), + """ + When the vehicle departed from the stop. Format is ISO8601/RFC 3339. `null` if the last stop on the trip or if the vehicle has not yet departed. + """, + example: "2026-03-13T10:43:00-04:00" + ) + end + + relationship(:trip) + relationship(:stop) + relationship(:route) + relationship(:vehicle) + end, + StopEvents: page(:StopEventResource), + StopEvent: single(:StopEventResource) + } + end +end diff --git a/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs b/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs new file mode 100644 index 000000000..cdbd6ee0b --- /dev/null +++ b/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs @@ -0,0 +1,503 @@ +defmodule ApiWeb.StopEventControllerTest do + @moduledoc false + use ApiWeb.ConnCase + + alias Model.{Route, Stop, StopEvent, Trip, Vehicle} + + @stop_event1 %StopEvent{ + id: "trip1-route1-v1-1", + vehicle_id: "v1", + trip_id: "trip1", + direction_id: 0, + route_id: "route1", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:28:06Z], + departed: ~U[2026-02-24 15:40:46Z] + } + + @stop_event2 %StopEvent{ + id: "trip2-route2-v2-1", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 1, + route_id: "route2", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :NON_REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event3 %StopEvent{ + id: "trip1-route1-v1-2", + vehicle_id: "v1", + trip_id: "trip1", + direction_id: 0, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 2, + arrived: ~U[2026-02-24 15:41:26Z], + departed: ~U[2026-02-24 15:42:13Z] + } + + @stop_event4 %StopEvent{ + id: "trip2-route1-v2-1", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 0, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event5 %StopEvent{ + id: "trip2-route1-v2-2", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 1, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event6 %StopEvent{ + id: "trip3-route2-v3-3", + vehicle_id: "v3", + trip_id: "trip3", + direction_id: 0, + route_id: "route2", + stop_id: "stop3", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:10:00Z], + departed: ~U[2026-02-24 16:11:40Z] + } + + @stop_event7 %StopEvent{ + id: "trip2-route1-1", + trip_id: "trip2", + direction_id: 1, + route_id: "route1", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event8 %StopEvent{ + id: "trip3-route1-v3-2", + vehicle_id: "v3", + trip_id: "trip3", + direction_id: 0, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:10:00Z], + departed: ~U[2026-02-24 16:11:40Z] + } + + @stop_event9 %StopEvent{ + id: "trip4-route2-v4-1", + vehicle_id: "v4", + trip_id: "trip4", + direction_id: 0, + route_id: "route2", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:26:40Z], + departed: ~U[2026-02-24 16:30:00Z] + } + + @stop_event10 %StopEvent{ + id: "trip2-route2-v2-1", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 1, + route_id: "route2", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:26:40Z], + departed: ~U[2026-02-24 16:30:00Z] + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + defp setup_schedule_state do + State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) + State.Stop.new_state([%Stop{id: "stop1"}]) + State.Route.new_state([%Route{id: "route1", type: 3}]) + + State.RoutePattern.new_state([ + %Model.RoutePattern{id: "route1-_-0", route_id: "route1", direction_id: 0} + ]) + + State.Schedule.new_state([ + %Model.Schedule{ + direction_id: 0, + route_id: "route1", + service_id: "service1", + stop_sequence: 1, + stop_id: "stop1", + trip_id: "trip1" + } + ]) + + State.RoutesPatternsAtStop.update!() + end + + defp setup_include_state do + State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) + State.Stop.new_state([%Stop{id: "stop1"}]) + State.Route.new_state([%Route{id: "route1", type: 3}]) + State.Vehicle.new_state([%Vehicle{id: "v1", latitude: 42.0, longitude: -71.0}]) + end + + describe "index_data/2" do + test "returns 400 with no filters", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = get(conn, stop_event_path(conn, :index)) + + assert json_response(conn, 400)["errors"] == [ + %{ + "status" => "400", + "code" => "bad_request", + "detail" => "At least one filter[] is required." + } + ] + end + + test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do + State.StopEvent.new_state([@stop_event1]) + + response = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}})) + assert validate_resp_schema(response, schema, "StopEvents") + end + + test "can filter by trip", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}})) + + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end + + test "can filter by stop", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event3]) + + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"stop" => "stop2"}})) + + assert [%{"id" => "trip1-route1-v1-2"}] = json_response(conn, 200)["data"] + end + + test "can filter by route", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"route" => "route1"}})) + + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end + + test "can filter by direction_id", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = + get(conn, stop_event_path(conn, :index, %{"filter" => %{"direction_id" => "0"}})) + + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end + + test "can filter by vehicle", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"vehicle" => "v1"}})) + + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end + + test "can filter by multiple parameters simultaneously", %{conn: conn} do + State.StopEvent.new_state([ + @stop_event1, + @stop_event3, + @stop_event4, + @stop_event5, + @stop_event6, + @stop_event7, + @stop_event8, + @stop_event9, + @stop_event10 + ]) + + for {filters, expected_ids} <- [ + # vehicle + route + {%{"vehicle" => "v1", "route" => "route1"}, + ["trip1-route1-v1-1", "trip1-route1-v1-2"]}, + # route + direction_id + {%{"route" => "route1", "direction_id" => "0"}, + ["trip1-route1-v1-1", "trip1-route1-v1-2", "trip2-route1-v2-1", "trip3-route1-v3-2"]}, + # trip + stop + {%{"trip" => "trip1", "stop" => "stop2"}, ["trip1-route1-v1-2"]}, + # route + stop + direction_id + {%{"route" => "route1", "stop" => "stop1", "direction_id" => "0"}, + ["trip1-route1-v1-1"]}, + # multiple trips + routes + stops + {%{"trip" => "trip1,trip2", "route" => "route1,route2", "stop" => "stop1"}, + ["trip1-route1-v1-1", "trip2-route1-1", "trip2-route2-v2-1"]}, + # filter by route + opposite direction + {%{"route" => "route1", "direction_id" => "1"}, + ["trip2-route1-1", "trip2-route1-v2-2"]} + ] do + conn = get(conn, stop_event_path(conn, :index, %{"filter" => filters})) + response_ids = json_response(conn, 200)["data"] |> Enum.map(& &1["id"]) |> Enum.sort() + assert response_ids == Enum.sort(expected_ids), "Failed for filters: #{inspect(filters)}" + end + end + + test "pagination works", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event3]) + + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "page" => %{"limit" => "1", "offset" => "0"} + }) + ) + + response = json_response(conn, 200) + assert length(response["data"]) == 1 + assert response["links"]["next"] + end + + test "can include individual resources", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + setup_include_state() + + for {include, expected_type, expected_id} <- [ + {"trip", "trip", "trip1"}, + {"stop", "stop", "stop1"}, + {"route", "route", "route1"}, + {"vehicle", "vehicle", "v1"} + ] do + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => include + }) + ) + + response = json_response(conn, 200) + assert [%{"type" => "stop_event"}] = response["data"] + assert [%{"type" => ^expected_type, "id" => ^expected_id}] = response["included"] + end + end + + test "can include multiple resources", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) + State.Stop.new_state([%Stop{id: "stop1"}]) + + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => "trip,stop" + }) + ) + + response = json_response(conn, 200) + assert [%{"type" => "stop_event"}] = response["data"] + included_types = response["included"] |> Enum.map(& &1["type"]) |> Enum.sort() + assert ["stop", "trip"] = included_types + end + + test "returns 400 for invalid include parameter", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => "invalid_field" + }) + ) + + assert json_response(conn, 400) + end + + test "can include schedule", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + setup_schedule_state() + + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => "schedule" + }) + ) + + response = json_response(conn, 200) + assert [%{"type" => "stop_event"}] = response["data"] + assert [%{"type" => "schedule", "id" => "schedule-trip1-stop1-1"}] = response["included"] + end + end + + describe "show_data/2" do + setup do + State.StopEvent.new_state([@stop_event1]) + :ok + end + + test "shows chosen resource", %{conn: conn} do + conn = get(conn, stop_event_path(conn, :show, @stop_event1.id)) + assert json_response(conn, 200)["data"]["id"] == @stop_event1.id + end + + test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do + response = get(conn, stop_event_path(conn, :show, @stop_event1.id)) + assert validate_resp_schema(response, schema, "StopEvent") + end + + test "does not show resource when id is nonexistent", %{conn: conn} do + conn = get(conn, stop_event_path(conn, :show, "nonexistent")) + assert json_response(conn, 404) + end + + test "does not allow filtering", %{conn: conn} do + conn = + get(conn, stop_event_path(conn, :show, @stop_event1.id, %{"filter[route]" => "route1"})) + + assert json_response(conn, 400) + end + + test "can include related resources", %{conn: conn} do + setup_include_state() + + conn = get(conn, stop_event_path(conn, :show, @stop_event1.id, %{"include" => "trip,stop"})) + + response = json_response(conn, 200) + assert response["data"]["id"] == @stop_event1.id + included_types = response["included"] |> Enum.map(& &1["type"]) |> Enum.sort() + assert ["stop", "trip"] = included_types + end + end + + describe "invalid parameters" do + test "returns 400 with invalid sort key", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = + get( + conn, + stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}, "sort" => "invalid"}) + ) + + assert %{"errors" => [error]} = json_response(conn, 400) + assert error["detail"] == "Invalid sort key." + end + + test "invalid direction_id values return empty results (lenient)", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event3]) + + for invalid_value <- ["2", "99", "-1", "abc", "invalid"] do + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1", "direction_id" => invalid_value} + }) + ) + + response = json_response(conn, 200) + + assert response["data"] == [], + "Invalid direction_id '#{invalid_value}' should return empty results" + end + end + + test "invalid pagination parameters are ignored (lenient)", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2, @stop_event3]) + + # Test cases: all should return 2 results (trip1 has 2 events) + for {param, value} <- [ + {"offset", "-1"}, + {"offset", "abc"}, + {"limit", "0"}, + {"limit", "-5"}, + {"limit", "xyz"} + ] do + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "page" => %{param => value} + }) + ) + + response = json_response(conn, 200) + + assert length(response["data"]) == 2, + "Invalid #{param}=#{value} should be ignored and return all results" + end + end + + test "empty or whitespace filter values return empty results", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + for filter_value <- ["", " "] do + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => filter_value}})) + response = json_response(conn, 200) + assert response["data"] == [] + end + end + + test "valid pagination with offset and limit works correctly", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2, @stop_event3, @stop_event4]) + + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"route" => "route1"}, + "page" => %{"offset" => "1", "limit" => "2"} + }) + ) + + response = json_response(conn, 200) + assert length(response["data"]) == 2 + end + end +end From a80219b21286c00849faf0c11cbf3242c5a4b3a7 Mon Sep 17 00:00:00 2001 From: "Corey N. Runkel" Date: Fri, 27 Mar 2026 14:53:08 -0400 Subject: [PATCH 4/4] Implement feature flags --- apps/api_web/config/dev.exs | 2 + apps/api_web/config/test.exs | 2 + .../controllers/stop_event_controller.ex | 540 +++++------ apps/api_web/lib/api_web/router.ex | 7 +- .../lib/api_web/views/stop_event_view.ex | 192 ++-- .../stop_event_controller_test.exs | 856 +++++++++--------- .../api_web/views/stop_event_view_test.exs | 440 ++++----- config/runtime.exs | 8 + 8 files changed, 1040 insertions(+), 1007 deletions(-) diff --git a/apps/api_web/config/dev.exs b/apps/api_web/config/dev.exs index 98a75d8e6..27433e03f 100644 --- a/apps/api_web/config/dev.exs +++ b/apps/api_web/config/dev.exs @@ -21,6 +21,8 @@ config :api_web, ApiWeb.Endpoint, config :api_web, ApiWeb.Plugs.ModifiedSinceHandler, check_caller: true +config :api_web, :features, stop_events_route: true + # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n", level: :debug diff --git a/apps/api_web/config/test.exs b/apps/api_web/config/test.exs index 212abe139..01ac5ba80 100644 --- a/apps/api_web/config/test.exs +++ b/apps/api_web/config/test.exs @@ -18,6 +18,8 @@ config :api_web, RateLimiter.Memcache, config :api_web, ApiWeb.Plugs.ModifiedSinceHandler, check_caller: true +config :api_web, :features, stop_events_route: true + config :sentry, test_mode: true, before_send: {ApiWeb.SentryEventFilter, :filter_event} diff --git a/apps/api_web/lib/api_web/controllers/stop_event_controller.ex b/apps/api_web/lib/api_web/controllers/stop_event_controller.ex index 34ac4f9f4..1ad77b25f 100644 --- a/apps/api_web/lib/api_web/controllers/stop_event_controller.ex +++ b/apps/api_web/lib/api_web/controllers/stop_event_controller.ex @@ -1,293 +1,295 @@ -defmodule ApiWeb.StopEventController do - @moduledoc """ - Controller for Stop Events. Filterable by: - - * trip - * stop - * route - * vehicle - * direction_id - """ - use ApiWeb.Web, :api_controller - alias State.StopEvent - - @filters ~w(trip stop route vehicle direction_id) - @includes ~w(trip stop route vehicle schedule) - @pagination_opts [:offset, :limit, :order_by] - @description """ - Stop events represent the actual arrival and departure times of vehicles at stops along their trips. - - Stop events are unique to the start_date, trip_id, route_id, vehicle_id, and stop_sequence. - - To return a list of stop events, **provide at least 1 filter parameter**; requests without filters will return an error. - """ - - def state_module, do: State.StopEvent - - swagger_path :index do - get(path("stop_event", :index)) - - description(""" - List of stop events. - - #{@description} - """) - - common_index_parameters(__MODULE__, :stop_event) - - include_parameters() - - parameter( - "filter[trip]", - :query, - :string, - "Filter by trip ID. #{comma_separated_list()}.", - example: "73885810" - ) - - parameter( - "filter[stop]", - :query, - :string, - "Filter by stop ID. #{comma_separated_list()}.", - example: "2231" - ) - - parameter( - "filter[route]", - :query, - :string, - "Filter by route ID. #{comma_separated_list()}.", - example: "64" - ) - - parameter( - "filter[vehicle]", - :query, - :string, - "Filter by vehicle ID. #{comma_separated_list()}.", - example: "y2071" - ) - - filter_param(:direction_id) - - consumes("application/vnd.api+json") - produces("application/vnd.api+json") - response(200, "OK", Schema.ref(:StopEvents)) - response(400, "Bad Request", Schema.ref(:BadRequest)) - response(403, "Forbidden", Schema.ref(:Forbidden)) - response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) - end - - def index_data(conn, params) do - with :ok <- Params.validate_includes(params, @includes, conn), - {:ok, filtered} <- Params.filter_params(params, @filters, conn) do - formatted_filters = format_filters(filtered) +if Application.compile_env(:api_web, [:features, :stop_events_route], false) do + defmodule ApiWeb.StopEventController do + @moduledoc """ + Controller for Stop Events. Filterable by: + + * trip + * stop + * route + * vehicle + * direction_id + """ + use ApiWeb.Web, :api_controller + alias State.StopEvent + + @filters ~w(trip stop route vehicle direction_id) + @includes ~w(trip stop route vehicle schedule) + @pagination_opts [:offset, :limit, :order_by] + @description """ + Stop events represent the actual arrival and departure times of vehicles at stops along their trips. + + Stop events are unique to the start_date, trip_id, route_id, vehicle_id, and stop_sequence. + + To return a list of stop events, **provide at least 1 filter parameter**; requests without filters will return an error. + """ + + def state_module, do: State.StopEvent + + swagger_path :index do + get(path("stop_event", :index)) + + description(""" + List of stop events. + + #{@description} + """) + + common_index_parameters(__MODULE__, :stop_event) + + include_parameters() + + parameter( + "filter[trip]", + :query, + :string, + "Filter by trip ID. #{comma_separated_list()}.", + example: "73885810" + ) + + parameter( + "filter[stop]", + :query, + :string, + "Filter by stop ID. #{comma_separated_list()}.", + example: "2231" + ) + + parameter( + "filter[route]", + :query, + :string, + "Filter by route ID. #{comma_separated_list()}.", + example: "64" + ) + + parameter( + "filter[vehicle]", + :query, + :string, + "Filter by vehicle ID. #{comma_separated_list()}.", + example: "y2071" + ) + + filter_param(:direction_id) + + consumes("application/vnd.api+json") + produces("application/vnd.api+json") + response(200, "OK", Schema.ref(:StopEvents)) + response(400, "Bad Request", Schema.ref(:BadRequest)) + response(403, "Forbidden", Schema.ref(:Forbidden)) + response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) + end - if map_size(formatted_filters) == 0 do - {:error, :filter_required} + def index_data(conn, params) do + with :ok <- Params.validate_includes(params, @includes, conn), + {:ok, filtered} <- Params.filter_params(params, @filters, conn) do + formatted_filters = format_filters(filtered) + + if map_size(formatted_filters) == 0 do + {:error, :filter_required} + else + formatted_filters + |> StopEvent.filter_by() + |> State.all(pagination_opts(params, conn)) + end else - formatted_filters - |> StopEvent.filter_by() - |> State.all(pagination_opts(params, conn)) + {:error, _, _} = error -> error end - else - {:error, _, _} = error -> error end - end - @spec format_filters(%{optional(String.t()) => String.t()}) :: StopEvent.filters() - defp format_filters(filters) do - Enum.reduce(filters, %{}, fn - {"trip", trip_ids}, acc -> - Map.put(acc, :trip_ids, Params.split_on_comma(trip_ids)) + @spec format_filters(%{optional(String.t()) => String.t()}) :: StopEvent.filters() + defp format_filters(filters) do + Enum.reduce(filters, %{}, fn + {"trip", trip_ids}, acc -> + Map.put(acc, :trip_ids, Params.split_on_comma(trip_ids)) - {"stop", stop_ids}, acc -> - Map.put(acc, :stop_ids, Params.split_on_comma(stop_ids)) + {"stop", stop_ids}, acc -> + Map.put(acc, :stop_ids, Params.split_on_comma(stop_ids)) - {"route", route_ids}, acc -> - Map.put(acc, :route_ids, Params.split_on_comma(route_ids)) + {"route", route_ids}, acc -> + Map.put(acc, :route_ids, Params.split_on_comma(route_ids)) - {"vehicle", vehicle_ids}, acc -> - Map.put(acc, :vehicle_ids, Params.split_on_comma(vehicle_ids)) + {"vehicle", vehicle_ids}, acc -> + Map.put(acc, :vehicle_ids, Params.split_on_comma(vehicle_ids)) - {"direction_id", direction_id}, acc -> - Map.put(acc, :direction_id, Params.direction_id(%{"direction_id" => direction_id})) + {"direction_id", direction_id}, acc -> + Map.put(acc, :direction_id, Params.direction_id(%{"direction_id" => direction_id})) - _, acc -> - acc - end) - end + _, acc -> + acc + end) + end + + defp pagination_opts(params, conn) do + opts = + params + |> Params.filter_opts(@pagination_opts, conn) - defp pagination_opts(params, conn) do - opts = - params - |> Params.filter_opts(@pagination_opts, conn) - - if is_list(opts) do - Keyword.put_new(opts, :order_by, {:arrived, :desc}) - else - opts - |> Map.to_list() - |> Keyword.put_new(:order_by, {:arrived, :desc}) + if is_list(opts) do + Keyword.put_new(opts, :order_by, {:arrived, :desc}) + else + opts + |> Map.to_list() + |> Keyword.put_new(:order_by, {:arrived, :desc}) + end end - end - swagger_path :show do - get(path("stop_event", :show)) + swagger_path :show do + get(path("stop_event", :show)) - description(""" - Show a particular stop event by its composite ID. + description(""" + Show a particular stop event by its composite ID. - #{@description} - """) + #{@description} + """) - parameter( - :id, - :path, - :string, - "Unique identifier for stop event (trip_id-route_id-vehicle_id-stop_sequence)" - ) + parameter( + :id, + :path, + :string, + "Unique identifier for stop event (trip_id-route_id-vehicle_id-stop_sequence)" + ) - include_parameters() + include_parameters() - consumes("application/vnd.api+json") - produces("application/vnd.api+json") + consumes("application/vnd.api+json") + produces("application/vnd.api+json") - response(200, "OK", Schema.ref(:StopEvent)) - response(403, "Forbidden", Schema.ref(:Forbidden)) - response(404, "Not Found", Schema.ref(:NotFound)) - response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) - end + response(200, "OK", Schema.ref(:StopEvent)) + response(403, "Forbidden", Schema.ref(:Forbidden)) + response(404, "Not Found", Schema.ref(:NotFound)) + response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) + end - def show_data(_conn, %{"id" => id}) do - StopEvent.by_id(id) - end + def show_data(_conn, %{"id" => id}) do + StopEvent.by_id(id) + end - defp include_parameters(schema) do - ApiWeb.SwaggerHelpers.include_parameters( - schema, - @includes, - description: """ - | include | Description | - |-|-| - | `trip` | The trip associated with this stop event. | - | `stop` | The stop where the event occurred. | - | `route` | The route associated with this stop event. | - | `vehicle` | The vehicle that served this trip. | - | `schedule` | The scheduled arrival/departure for this stop event. | - - Note that the included entities may appear in past events but no longer in realtime feeds, so included relationships may be empty. - """ - ) - end + defp include_parameters(schema) do + ApiWeb.SwaggerHelpers.include_parameters( + schema, + @includes, + description: """ + | include | Description | + |-|-| + | `trip` | The trip associated with this stop event. | + | `stop` | The stop where the event occurred. | + | `route` | The route associated with this stop event. | + | `vehicle` | The vehicle that served this trip. | + | `schedule` | The scheduled arrival/departure for this stop event. | + + Note that the included entities may appear in past events but no longer in realtime feeds, so included relationships may be empty. + """ + ) + end - def swagger_definitions do - import PhoenixSwagger.JsonApi, except: [page: 1] - - %{ - StopEventResource: - resource do - description(""" - Actual arrival and departure times of vehicles at stops. - """) - - attributes do - vehicle_id( - :string, - """ - The vehicle ID that served this trip. - """, - example: "y2071" - ) - - start_date( - :string, - """ - The service date of the trip in YYYY-MM-DD format. - """, - example: "2026-02-24", - format: :date - ) - - trip_id( - :string, - """ - The trip ID associated with this stop event. - """, - example: "73885810" - ) - - direction_id( - :integer, - """ - Direction in which the trip is traveling: - - `0` - Travel in one direction (e.g. outbound travel) - - `1` - Travel in the opposite direction (e.g. inbound travel) - """, - enum: [0, 1], - example: 0 - ) - - route_id( - :string, - """ - The route ID associated with this stop event. - """, - example: "64" - ) - - revenue( - :string, - """ - Whether this stop event is for a revenue trip: - - `REVENUE` - A revenue trip - - `NON_REVENUE` - A non-revenue trip - """, - enum: ["REVENUE", "NON_REVENUE"], - example: "REVENUE" - ) - - stop_id( - :string, - """ - The stop ID where the event occurred. - """, - example: "2231" - ) - - stop_sequence( - :integer, - """ - The stop sequence number along the trip. Increases monotonically but values need not be consecutive. - """, - example: 1 - ) - - arrived( - nullable(%Schema{type: :string, format: :"date-time"}, true), - """ - When the vehicle arrived at the stop. Format is ISO8601/RFC 3339. `null` if the first stop on the trip. - """, - example: "2026-03-13T10:30:00-04:00" - ) - - departed( - nullable(%Schema{type: :string, format: :"date-time"}, true), - """ - When the vehicle departed from the stop. Format is ISO8601/RFC 3339. `null` if the last stop on the trip or if the vehicle has not yet departed. - """, - example: "2026-03-13T10:43:00-04:00" - ) - end - - relationship(:trip) - relationship(:stop) - relationship(:route) - relationship(:vehicle) - end, - StopEvents: page(:StopEventResource), - StopEvent: single(:StopEventResource) - } + def swagger_definitions do + import PhoenixSwagger.JsonApi, except: [page: 1] + + %{ + StopEventResource: + resource do + description(""" + Actual arrival and departure times of vehicles at stops. + """) + + attributes do + vehicle_id( + :string, + """ + The vehicle ID that served this trip. + """, + example: "y2071" + ) + + start_date( + :string, + """ + The service date of the trip in YYYY-MM-DD format. + """, + example: "2026-02-24", + format: :date + ) + + trip_id( + :string, + """ + The trip ID associated with this stop event. + """, + example: "73885810" + ) + + direction_id( + :integer, + """ + Direction in which the trip is traveling: + - `0` - Travel in one direction (e.g. outbound travel) + - `1` - Travel in the opposite direction (e.g. inbound travel) + """, + enum: [0, 1], + example: 0 + ) + + route_id( + :string, + """ + The route ID associated with this stop event. + """, + example: "64" + ) + + revenue( + :string, + """ + Whether this stop event is for a revenue trip: + - `REVENUE` - A revenue trip + - `NON_REVENUE` - A non-revenue trip + """, + enum: ["REVENUE", "NON_REVENUE"], + example: "REVENUE" + ) + + stop_id( + :string, + """ + The stop ID where the event occurred. + """, + example: "2231" + ) + + stop_sequence( + :integer, + """ + The stop sequence number along the trip. Increases monotonically but values need not be consecutive. + """, + example: 1 + ) + + arrived( + nullable(%Schema{type: :string, format: :"date-time"}, true), + """ + When the vehicle arrived at the stop. Format is ISO8601/RFC 3339. `null` if the first stop on the trip. + """, + example: "2026-03-13T10:30:00-04:00" + ) + + departed( + nullable(%Schema{type: :string, format: :"date-time"}, true), + """ + When the vehicle departed from the stop. Format is ISO8601/RFC 3339. `null` if the last stop on the trip or if the vehicle has not yet departed. + """, + example: "2026-03-13T10:43:00-04:00" + ) + end + + relationship(:trip) + relationship(:stop) + relationship(:route) + relationship(:vehicle) + end, + StopEvents: page(:StopEventResource), + StopEvent: single(:StopEventResource) + } + end end end diff --git a/apps/api_web/lib/api_web/router.ex b/apps/api_web/lib/api_web/router.ex index d8be1b907..65ae57c99 100644 --- a/apps/api_web/lib/api_web/router.ex +++ b/apps/api_web/lib/api_web/router.ex @@ -100,8 +100,11 @@ defmodule ApiWeb.Router do resources("/live_facilities", LiveFacilityController, only: [:index, :show]) resources("/live-facilities", LiveFacilityController, only: [:index, :show]) resources("/services", ServiceController, only: [:index, :show]) - resources("/stop_events", StopEventController, only: [:index, :show]) - resources("/stop-events", StopEventController, only: [:index, :show]) + + if Application.compile_env(:api_web, [:features, :stop_events_route], false) do + resources("/stop_events", StopEventController, only: [:index, :show]) + resources("/stop-events", StopEventController, only: [:index, :show]) + end end scope "/docs/swagger" do diff --git a/apps/api_web/lib/api_web/views/stop_event_view.ex b/apps/api_web/lib/api_web/views/stop_event_view.ex index 001cb181c..1c6e31487 100644 --- a/apps/api_web/lib/api_web/views/stop_event_view.ex +++ b/apps/api_web/lib/api_web/views/stop_event_view.ex @@ -1,103 +1,105 @@ -defmodule ApiWeb.StopEventView do - use ApiWeb.Web, :api_view - - location(:stop_event_location) - - def stop_event_location(stop_event, conn), - do: stop_event_path(conn, :show, stop_event.id) - - attributes([ - :start_date, - :direction_id, - :revenue, - :stop_sequence, - :arrived, - :departed - ]) - - has_one( - :trip, - type: :trip, - serializer: ApiWeb.TripView, - field: :trip_id - ) - - has_one( - :stop, - type: :stop, - serializer: ApiWeb.StopView, - field: :stop_id - ) - - has_one( - :route, - type: :route, - serializer: ApiWeb.RouteView, - field: :route_id - ) - - has_one( - :vehicle, - type: :vehicle, - serializer: ApiWeb.VehicleView, - field: :vehicle_id - ) - - def arrived(%{arrived: nil}, _conn), do: nil - def arrived(%{arrived: %DateTime{} = dt}, _conn), do: DateTime.to_iso8601(dt) - - def departed(%{departed: nil}, _conn), do: nil - def departed(%{departed: %DateTime{} = dt}, _conn), do: DateTime.to_iso8601(dt) - - @doc """ - Preloads relationships for stop events when requested via ?include=* to prevent N+1 queries. - """ - def preload(stop_events, conn, include_opts) when is_list(stop_events) do - stop_events = super(stop_events, conn, include_opts) - - if split_included?("schedule", conn) do - schedules = State.Schedule.schedule_for_many(stop_events) - - Enum.map(stop_events, fn stop_event -> - schedule = Map.get(schedules, {stop_event.trip_id, stop_event.stop_sequence}) - Map.put(stop_event, :schedule, schedule) - end) - else - stop_events +if Application.compile_env(:api_web, [:features, :stop_events_route], false) do + defmodule ApiWeb.StopEventView do + use ApiWeb.Web, :api_view + + location(:stop_event_location) + + def stop_event_location(stop_event, conn), + do: stop_event_path(conn, :show, stop_event.id) + + attributes([ + :start_date, + :direction_id, + :revenue, + :stop_sequence, + :arrived, + :departed + ]) + + has_one( + :trip, + type: :trip, + serializer: ApiWeb.TripView, + field: :trip_id + ) + + has_one( + :stop, + type: :stop, + serializer: ApiWeb.StopView, + field: :stop_id + ) + + has_one( + :route, + type: :route, + serializer: ApiWeb.RouteView, + field: :route_id + ) + + has_one( + :vehicle, + type: :vehicle, + serializer: ApiWeb.VehicleView, + field: :vehicle_id + ) + + def arrived(%{arrived: nil}, _conn), do: nil + def arrived(%{arrived: %DateTime{} = dt}, _conn), do: DateTime.to_iso8601(dt) + + def departed(%{departed: nil}, _conn), do: nil + def departed(%{departed: %DateTime{} = dt}, _conn), do: DateTime.to_iso8601(dt) + + @doc """ + Preloads relationships for stop events when requested via ?include=* to prevent N+1 queries. + """ + def preload(stop_events, conn, include_opts) when is_list(stop_events) do + stop_events = super(stop_events, conn, include_opts) + + if split_included?("schedule", conn) do + schedules = State.Schedule.schedule_for_many(stop_events) + + Enum.map(stop_events, fn stop_event -> + schedule = Map.get(schedules, {stop_event.trip_id, stop_event.stop_sequence}) + Map.put(stop_event, :schedule, schedule) + end) + else + stop_events + end end - end - def preload(stop_event, conn, _opts) do - if split_included?("schedule", conn) do - schedule = State.Schedule.schedule_for(stop_event) - Map.put(stop_event, :schedule, schedule) - else - stop_event + def preload(stop_event, conn, _opts) do + if split_included?("schedule", conn) do + schedule = State.Schedule.schedule_for(stop_event) + Map.put(stop_event, :schedule, schedule) + else + stop_event + end end - end - def relationships(stop_event, conn) do - # Get the base relationships as a map from has_one macros - base_relationships = super(stop_event, conn) - - if split_included?("schedule", conn) do - Map.put( - base_relationships, - :schedule, - %HasOne{ - type: :schedule, - name: :schedule, - data: schedule(stop_event, conn), - serializer: ApiWeb.ScheduleView - } - ) - else - base_relationships + def relationships(stop_event, conn) do + # Get the base relationships as a map from has_one macros + base_relationships = super(stop_event, conn) + + if split_included?("schedule", conn) do + Map.put( + base_relationships, + :schedule, + %HasOne{ + type: :schedule, + name: :schedule, + data: schedule(stop_event, conn), + serializer: ApiWeb.ScheduleView + } + ) + else + base_relationships + end end - end - defp schedule(%{schedule: schedule}, _conn), do: schedule + defp schedule(%{schedule: schedule}, _conn), do: schedule - defp schedule(stop_event, conn), - do: optional_relationship("schedule", stop_event, &State.Schedule.schedule_for/1, conn) + defp schedule(stop_event, conn), + do: optional_relationship("schedule", stop_event, &State.Schedule.schedule_for/1, conn) + end end diff --git a/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs b/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs index cdbd6ee0b..82c8e1c22 100644 --- a/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs +++ b/apps/api_web/test/api_web/controllers/stop_event_controller_test.exs @@ -1,503 +1,515 @@ -defmodule ApiWeb.StopEventControllerTest do - @moduledoc false - use ApiWeb.ConnCase - - alias Model.{Route, Stop, StopEvent, Trip, Vehicle} - - @stop_event1 %StopEvent{ - id: "trip1-route1-v1-1", - vehicle_id: "v1", - trip_id: "trip1", - direction_id: 0, - route_id: "route1", - stop_id: "stop1", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 15:28:06Z], - departed: ~U[2026-02-24 15:40:46Z] - } - - @stop_event2 %StopEvent{ - id: "trip2-route2-v2-1", - vehicle_id: "v2", - trip_id: "trip2", - direction_id: 1, - route_id: "route2", - stop_id: "stop2", - start_date: ~D[2026-02-24], - revenue: :NON_REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 15:59:03Z], - departed: nil - } - - @stop_event3 %StopEvent{ - id: "trip1-route1-v1-2", - vehicle_id: "v1", - trip_id: "trip1", - direction_id: 0, - route_id: "route1", - stop_id: "stop2", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 2, - arrived: ~U[2026-02-24 15:41:26Z], - departed: ~U[2026-02-24 15:42:13Z] - } - - @stop_event4 %StopEvent{ - id: "trip2-route1-v2-1", - vehicle_id: "v2", - trip_id: "trip2", - direction_id: 0, - route_id: "route1", - stop_id: "stop2", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 15:59:03Z], - departed: nil - } - - @stop_event5 %StopEvent{ - id: "trip2-route1-v2-2", - vehicle_id: "v2", - trip_id: "trip2", - direction_id: 1, - route_id: "route1", - stop_id: "stop2", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 15:59:03Z], - departed: nil - } - - @stop_event6 %StopEvent{ - id: "trip3-route2-v3-3", - vehicle_id: "v3", - trip_id: "trip3", - direction_id: 0, - route_id: "route2", - stop_id: "stop3", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 16:10:00Z], - departed: ~U[2026-02-24 16:11:40Z] - } - - @stop_event7 %StopEvent{ - id: "trip2-route1-1", - trip_id: "trip2", - direction_id: 1, - route_id: "route1", - stop_id: "stop1", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 15:59:03Z], - departed: nil - } - - @stop_event8 %StopEvent{ - id: "trip3-route1-v3-2", - vehicle_id: "v3", - trip_id: "trip3", - direction_id: 0, - route_id: "route1", - stop_id: "stop2", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 16:10:00Z], - departed: ~U[2026-02-24 16:11:40Z] - } - - @stop_event9 %StopEvent{ - id: "trip4-route2-v4-1", - vehicle_id: "v4", - trip_id: "trip4", - direction_id: 0, - route_id: "route2", - stop_id: "stop1", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 16:26:40Z], - departed: ~U[2026-02-24 16:30:00Z] - } - - @stop_event10 %StopEvent{ - id: "trip2-route2-v2-1", - vehicle_id: "v2", - trip_id: "trip2", - direction_id: 1, - route_id: "route2", - stop_id: "stop1", - start_date: ~D[2026-02-24], - revenue: :REVENUE, - stop_sequence: 1, - arrived: ~U[2026-02-24 16:26:40Z], - departed: ~U[2026-02-24 16:30:00Z] - } - - setup %{conn: conn} do - {:ok, conn: put_req_header(conn, "accept", "application/json")} - end +if Application.compile_env(:api_web, [:features, :stop_events_route], false) do + defmodule ApiWeb.StopEventControllerTest do + @moduledoc false + use ApiWeb.ConnCase + + alias Model.{Route, Stop, StopEvent, Trip, Vehicle} + + @stop_event1 %StopEvent{ + id: "trip1-route1-v1-1", + vehicle_id: "v1", + trip_id: "trip1", + direction_id: 0, + route_id: "route1", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:28:06Z], + departed: ~U[2026-02-24 15:40:46Z] + } + + @stop_event2 %StopEvent{ + id: "trip2-route2-v2-1", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 1, + route_id: "route2", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :NON_REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event3 %StopEvent{ + id: "trip1-route1-v1-2", + vehicle_id: "v1", + trip_id: "trip1", + direction_id: 0, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 2, + arrived: ~U[2026-02-24 15:41:26Z], + departed: ~U[2026-02-24 15:42:13Z] + } + + @stop_event4 %StopEvent{ + id: "trip2-route1-v2-1", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 0, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event5 %StopEvent{ + id: "trip2-route1-v2-2", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 1, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event6 %StopEvent{ + id: "trip3-route2-v3-3", + vehicle_id: "v3", + trip_id: "trip3", + direction_id: 0, + route_id: "route2", + stop_id: "stop3", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:10:00Z], + departed: ~U[2026-02-24 16:11:40Z] + } + + @stop_event7 %StopEvent{ + id: "trip2-route1-1", + trip_id: "trip2", + direction_id: 1, + route_id: "route1", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 15:59:03Z], + departed: nil + } + + @stop_event8 %StopEvent{ + id: "trip3-route1-v3-2", + vehicle_id: "v3", + trip_id: "trip3", + direction_id: 0, + route_id: "route1", + stop_id: "stop2", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:10:00Z], + departed: ~U[2026-02-24 16:11:40Z] + } + + @stop_event9 %StopEvent{ + id: "trip4-route2-v4-1", + vehicle_id: "v4", + trip_id: "trip4", + direction_id: 0, + route_id: "route2", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:26:40Z], + departed: ~U[2026-02-24 16:30:00Z] + } + + @stop_event10 %StopEvent{ + id: "trip2-route2-v2-1", + vehicle_id: "v2", + trip_id: "trip2", + direction_id: 1, + route_id: "route2", + stop_id: "stop1", + start_date: ~D[2026-02-24], + revenue: :REVENUE, + stop_sequence: 1, + arrived: ~U[2026-02-24 16:26:40Z], + departed: ~U[2026-02-24 16:30:00Z] + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end - defp setup_schedule_state do - State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) - State.Stop.new_state([%Stop{id: "stop1"}]) - State.Route.new_state([%Route{id: "route1", type: 3}]) - - State.RoutePattern.new_state([ - %Model.RoutePattern{id: "route1-_-0", route_id: "route1", direction_id: 0} - ]) - - State.Schedule.new_state([ - %Model.Schedule{ - direction_id: 0, - route_id: "route1", - service_id: "service1", - stop_sequence: 1, - stop_id: "stop1", - trip_id: "trip1" - } - ]) - - State.RoutesPatternsAtStop.update!() - end + defp setup_schedule_state do + State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) + State.Stop.new_state([%Stop{id: "stop1"}]) + State.Route.new_state([%Route{id: "route1", type: 3}]) - defp setup_include_state do - State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) - State.Stop.new_state([%Stop{id: "stop1"}]) - State.Route.new_state([%Route{id: "route1", type: 3}]) - State.Vehicle.new_state([%Vehicle{id: "v1", latitude: 42.0, longitude: -71.0}]) - end + State.RoutePattern.new_state([ + %Model.RoutePattern{id: "route1-_-0", route_id: "route1", direction_id: 0} + ]) - describe "index_data/2" do - test "returns 400 with no filters", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) + State.Schedule.new_state([ + %Model.Schedule{ + direction_id: 0, + route_id: "route1", + service_id: "service1", + stop_sequence: 1, + stop_id: "stop1", + trip_id: "trip1" + } + ]) - conn = get(conn, stop_event_path(conn, :index)) + State.RoutesPatternsAtStop.update!() + end - assert json_response(conn, 400)["errors"] == [ - %{ - "status" => "400", - "code" => "bad_request", - "detail" => "At least one filter[] is required." - } - ] + defp setup_include_state do + State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) + State.Stop.new_state([%Stop{id: "stop1"}]) + State.Route.new_state([%Route{id: "route1", type: 3}]) + State.Vehicle.new_state([%Vehicle{id: "v1", latitude: 42.0, longitude: -71.0}]) end - test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do - State.StopEvent.new_state([@stop_event1]) + describe "index_data/2" do + test "returns 400 with no filters", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) - response = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}})) - assert validate_resp_schema(response, schema, "StopEvents") - end + conn = get(conn, stop_event_path(conn, :index)) - test "can filter by trip", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) + assert json_response(conn, 400)["errors"] == [ + %{ + "status" => "400", + "code" => "bad_request", + "detail" => "At least one filter[] is required." + } + ] + end - conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}})) + test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do + State.StopEvent.new_state([@stop_event1]) - assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] - end + response = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}})) + assert validate_resp_schema(response, schema, "StopEvents") + end - test "can filter by stop", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event3]) + test "can filter by trip", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) - conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"stop" => "stop2"}})) + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}})) - assert [%{"id" => "trip1-route1-v1-2"}] = json_response(conn, 200)["data"] - end + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end - test "can filter by route", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) + test "can filter by stop", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event3]) - conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"route" => "route1"}})) + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"stop" => "stop2"}})) - assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] - end + assert [%{"id" => "trip1-route1-v1-2"}] = json_response(conn, 200)["data"] + end - test "can filter by direction_id", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) + test "can filter by route", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) - conn = - get(conn, stop_event_path(conn, :index, %{"filter" => %{"direction_id" => "0"}})) + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"route" => "route1"}})) - assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] - end + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end - test "can filter by vehicle", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) + test "can filter by direction_id", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) - conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"vehicle" => "v1"}})) + conn = + get(conn, stop_event_path(conn, :index, %{"filter" => %{"direction_id" => "0"}})) - assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] - end + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] + end - test "can filter by multiple parameters simultaneously", %{conn: conn} do - State.StopEvent.new_state([ - @stop_event1, - @stop_event3, - @stop_event4, - @stop_event5, - @stop_event6, - @stop_event7, - @stop_event8, - @stop_event9, - @stop_event10 - ]) + test "can filter by vehicle", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"vehicle" => "v1"}})) - for {filters, expected_ids} <- [ - # vehicle + route - {%{"vehicle" => "v1", "route" => "route1"}, - ["trip1-route1-v1-1", "trip1-route1-v1-2"]}, - # route + direction_id - {%{"route" => "route1", "direction_id" => "0"}, - ["trip1-route1-v1-1", "trip1-route1-v1-2", "trip2-route1-v2-1", "trip3-route1-v3-2"]}, - # trip + stop - {%{"trip" => "trip1", "stop" => "stop2"}, ["trip1-route1-v1-2"]}, - # route + stop + direction_id - {%{"route" => "route1", "stop" => "stop1", "direction_id" => "0"}, - ["trip1-route1-v1-1"]}, - # multiple trips + routes + stops - {%{"trip" => "trip1,trip2", "route" => "route1,route2", "stop" => "stop1"}, - ["trip1-route1-v1-1", "trip2-route1-1", "trip2-route2-v2-1"]}, - # filter by route + opposite direction - {%{"route" => "route1", "direction_id" => "1"}, - ["trip2-route1-1", "trip2-route1-v2-2"]} - ] do - conn = get(conn, stop_event_path(conn, :index, %{"filter" => filters})) - response_ids = json_response(conn, 200)["data"] |> Enum.map(& &1["id"]) |> Enum.sort() - assert response_ids == Enum.sort(expected_ids), "Failed for filters: #{inspect(filters)}" + assert [%{"id" => "trip1-route1-v1-1"}] = json_response(conn, 200)["data"] end - end - test "pagination works", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event3]) - - conn = - get( - conn, - stop_event_path(conn, :index, %{ - "filter" => %{"trip" => "trip1"}, - "page" => %{"limit" => "1", "offset" => "0"} - }) - ) - - response = json_response(conn, 200) - assert length(response["data"]) == 1 - assert response["links"]["next"] - end + test "can filter by multiple parameters simultaneously", %{conn: conn} do + State.StopEvent.new_state([ + @stop_event1, + @stop_event3, + @stop_event4, + @stop_event5, + @stop_event6, + @stop_event7, + @stop_event8, + @stop_event9, + @stop_event10 + ]) + + for {filters, expected_ids} <- [ + # vehicle + route + {%{"vehicle" => "v1", "route" => "route1"}, + ["trip1-route1-v1-1", "trip1-route1-v1-2"]}, + # route + direction_id + {%{"route" => "route1", "direction_id" => "0"}, + [ + "trip1-route1-v1-1", + "trip1-route1-v1-2", + "trip2-route1-v2-1", + "trip3-route1-v3-2" + ]}, + # trip + stop + {%{"trip" => "trip1", "stop" => "stop2"}, ["trip1-route1-v1-2"]}, + # route + stop + direction_id + {%{"route" => "route1", "stop" => "stop1", "direction_id" => "0"}, + ["trip1-route1-v1-1"]}, + # multiple trips + routes + stops + {%{"trip" => "trip1,trip2", "route" => "route1,route2", "stop" => "stop1"}, + ["trip1-route1-v1-1", "trip2-route1-1", "trip2-route2-v2-1"]}, + # filter by route + opposite direction + {%{"route" => "route1", "direction_id" => "1"}, + ["trip2-route1-1", "trip2-route1-v2-2"]} + ] do + conn = get(conn, stop_event_path(conn, :index, %{"filter" => filters})) + response_ids = json_response(conn, 200)["data"] |> Enum.map(& &1["id"]) |> Enum.sort() + + assert response_ids == Enum.sort(expected_ids), + "Failed for filters: #{inspect(filters)}" + end + end - test "can include individual resources", %{conn: conn} do - State.StopEvent.new_state([@stop_event1]) - setup_include_state() + test "pagination works", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event3]) - for {include, expected_type, expected_id} <- [ - {"trip", "trip", "trip1"}, - {"stop", "stop", "stop1"}, - {"route", "route", "route1"}, - {"vehicle", "vehicle", "v1"} - ] do conn = get( conn, stop_event_path(conn, :index, %{ "filter" => %{"trip" => "trip1"}, - "include" => include + "page" => %{"limit" => "1", "offset" => "0"} }) ) response = json_response(conn, 200) - assert [%{"type" => "stop_event"}] = response["data"] - assert [%{"type" => ^expected_type, "id" => ^expected_id}] = response["included"] + assert length(response["data"]) == 1 + assert response["links"]["next"] end - end - test "can include multiple resources", %{conn: conn} do - State.StopEvent.new_state([@stop_event1]) - State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) - State.Stop.new_state([%Stop{id: "stop1"}]) + test "can include individual resources", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + setup_include_state() + + for {include, expected_type, expected_id} <- [ + {"trip", "trip", "trip1"}, + {"stop", "stop", "stop1"}, + {"route", "route", "route1"}, + {"vehicle", "vehicle", "v1"} + ] do + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => include + }) + ) + + response = json_response(conn, 200) + assert [%{"type" => "stop_event"}] = response["data"] + assert [%{"type" => ^expected_type, "id" => ^expected_id}] = response["included"] + end + end - conn = - get( - conn, - stop_event_path(conn, :index, %{ - "filter" => %{"trip" => "trip1"}, - "include" => "trip,stop" - }) - ) - - response = json_response(conn, 200) - assert [%{"type" => "stop_event"}] = response["data"] - included_types = response["included"] |> Enum.map(& &1["type"]) |> Enum.sort() - assert ["stop", "trip"] = included_types - end + test "can include multiple resources", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + State.Trip.new_state([%Trip{id: "trip1", route_id: "route1", direction_id: 0}]) + State.Stop.new_state([%Stop{id: "stop1"}]) - test "returns 400 for invalid include parameter", %{conn: conn} do - State.StopEvent.new_state([@stop_event1]) + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => "trip,stop" + }) + ) - conn = - get( - conn, - stop_event_path(conn, :index, %{ - "filter" => %{"trip" => "trip1"}, - "include" => "invalid_field" - }) - ) + response = json_response(conn, 200) + assert [%{"type" => "stop_event"}] = response["data"] + included_types = response["included"] |> Enum.map(& &1["type"]) |> Enum.sort() + assert ["stop", "trip"] = included_types + end - assert json_response(conn, 400) - end + test "returns 400 for invalid include parameter", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) - test "can include schedule", %{conn: conn} do - State.StopEvent.new_state([@stop_event1]) - setup_schedule_state() - - conn = - get( - conn, - stop_event_path(conn, :index, %{ - "filter" => %{"trip" => "trip1"}, - "include" => "schedule" - }) - ) - - response = json_response(conn, 200) - assert [%{"type" => "stop_event"}] = response["data"] - assert [%{"type" => "schedule", "id" => "schedule-trip1-stop1-1"}] = response["included"] - end - end + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => "invalid_field" + }) + ) - describe "show_data/2" do - setup do - State.StopEvent.new_state([@stop_event1]) - :ok - end + assert json_response(conn, 400) + end - test "shows chosen resource", %{conn: conn} do - conn = get(conn, stop_event_path(conn, :show, @stop_event1.id)) - assert json_response(conn, 200)["data"]["id"] == @stop_event1.id - end + test "can include schedule", %{conn: conn} do + State.StopEvent.new_state([@stop_event1]) + setup_schedule_state() - test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do - response = get(conn, stop_event_path(conn, :show, @stop_event1.id)) - assert validate_resp_schema(response, schema, "StopEvent") - end + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "include" => "schedule" + }) + ) - test "does not show resource when id is nonexistent", %{conn: conn} do - conn = get(conn, stop_event_path(conn, :show, "nonexistent")) - assert json_response(conn, 404) + response = json_response(conn, 200) + assert [%{"type" => "stop_event"}] = response["data"] + assert [%{"type" => "schedule", "id" => "schedule-trip1-stop1-1"}] = response["included"] + end end - test "does not allow filtering", %{conn: conn} do - conn = - get(conn, stop_event_path(conn, :show, @stop_event1.id, %{"filter[route]" => "route1"})) + describe "show_data/2" do + setup do + State.StopEvent.new_state([@stop_event1]) + :ok + end - assert json_response(conn, 400) - end + test "shows chosen resource", %{conn: conn} do + conn = get(conn, stop_event_path(conn, :show, @stop_event1.id)) + assert json_response(conn, 200)["data"]["id"] == @stop_event1.id + end - test "can include related resources", %{conn: conn} do - setup_include_state() + test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do + response = get(conn, stop_event_path(conn, :show, @stop_event1.id)) + assert validate_resp_schema(response, schema, "StopEvent") + end - conn = get(conn, stop_event_path(conn, :show, @stop_event1.id, %{"include" => "trip,stop"})) + test "does not show resource when id is nonexistent", %{conn: conn} do + conn = get(conn, stop_event_path(conn, :show, "nonexistent")) + assert json_response(conn, 404) + end - response = json_response(conn, 200) - assert response["data"]["id"] == @stop_event1.id - included_types = response["included"] |> Enum.map(& &1["type"]) |> Enum.sort() - assert ["stop", "trip"] = included_types - end - end + test "does not allow filtering", %{conn: conn} do + conn = + get(conn, stop_event_path(conn, :show, @stop_event1.id, %{"filter[route]" => "route1"})) + + assert json_response(conn, 400) + end - describe "invalid parameters" do - test "returns 400 with invalid sort key", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) + test "can include related resources", %{conn: conn} do + setup_include_state() - conn = - get( - conn, - stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}, "sort" => "invalid"}) - ) + conn = + get(conn, stop_event_path(conn, :show, @stop_event1.id, %{"include" => "trip,stop"})) - assert %{"errors" => [error]} = json_response(conn, 400) - assert error["detail"] == "Invalid sort key." + response = json_response(conn, 200) + assert response["data"]["id"] == @stop_event1.id + included_types = response["included"] |> Enum.map(& &1["type"]) |> Enum.sort() + assert ["stop", "trip"] = included_types + end end - test "invalid direction_id values return empty results (lenient)", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event3]) + describe "invalid parameters" do + test "returns 400 with invalid sort key", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) - for invalid_value <- ["2", "99", "-1", "abc", "invalid"] do conn = get( conn, - stop_event_path(conn, :index, %{ - "filter" => %{"trip" => "trip1", "direction_id" => invalid_value} - }) + stop_event_path(conn, :index, %{"filter" => %{"trip" => "trip1"}, "sort" => "invalid"}) ) - response = json_response(conn, 200) + assert %{"errors" => [error]} = json_response(conn, 400) + assert error["detail"] == "Invalid sort key." + end + + test "invalid direction_id values return empty results (lenient)", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event3]) + + for invalid_value <- ["2", "99", "-1", "abc", "invalid"] do + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1", "direction_id" => invalid_value} + }) + ) - assert response["data"] == [], - "Invalid direction_id '#{invalid_value}' should return empty results" + response = json_response(conn, 200) + + assert response["data"] == [], + "Invalid direction_id '#{invalid_value}' should return empty results" + end + end + + test "invalid pagination parameters are ignored (lenient)", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2, @stop_event3]) + + # Test cases: all should return 2 results (trip1 has 2 events) + for {param, value} <- [ + {"offset", "-1"}, + {"offset", "abc"}, + {"limit", "0"}, + {"limit", "-5"}, + {"limit", "xyz"} + ] do + conn = + get( + conn, + stop_event_path(conn, :index, %{ + "filter" => %{"trip" => "trip1"}, + "page" => %{param => value} + }) + ) + + response = json_response(conn, 200) + + assert length(response["data"]) == 2, + "Invalid #{param}=#{value} should be ignored and return all results" + end end - end - test "invalid pagination parameters are ignored (lenient)", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2, @stop_event3]) - - # Test cases: all should return 2 results (trip1 has 2 events) - for {param, value} <- [ - {"offset", "-1"}, - {"offset", "abc"}, - {"limit", "0"}, - {"limit", "-5"}, - {"limit", "xyz"} - ] do + test "empty or whitespace filter values return empty results", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2]) + + for filter_value <- ["", " "] do + conn = + get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => filter_value}})) + + response = json_response(conn, 200) + assert response["data"] == [] + end + end + + test "valid pagination with offset and limit works correctly", %{conn: conn} do + State.StopEvent.new_state([@stop_event1, @stop_event2, @stop_event3, @stop_event4]) + conn = get( conn, stop_event_path(conn, :index, %{ - "filter" => %{"trip" => "trip1"}, - "page" => %{param => value} + "filter" => %{"route" => "route1"}, + "page" => %{"offset" => "1", "limit" => "2"} }) ) response = json_response(conn, 200) - - assert length(response["data"]) == 2, - "Invalid #{param}=#{value} should be ignored and return all results" + assert length(response["data"]) == 2 end end - - test "empty or whitespace filter values return empty results", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2]) - - for filter_value <- ["", " "] do - conn = get(conn, stop_event_path(conn, :index, %{"filter" => %{"trip" => filter_value}})) - response = json_response(conn, 200) - assert response["data"] == [] - end - end - - test "valid pagination with offset and limit works correctly", %{conn: conn} do - State.StopEvent.new_state([@stop_event1, @stop_event2, @stop_event3, @stop_event4]) - - conn = - get( - conn, - stop_event_path(conn, :index, %{ - "filter" => %{"route" => "route1"}, - "page" => %{"offset" => "1", "limit" => "2"} - }) - ) - - response = json_response(conn, 200) - assert length(response["data"]) == 2 - end end end diff --git a/apps/api_web/test/api_web/views/stop_event_view_test.exs b/apps/api_web/test/api_web/views/stop_event_view_test.exs index 9c6527dc0..0487f4ce7 100644 --- a/apps/api_web/test/api_web/views/stop_event_view_test.exs +++ b/apps/api_web/test/api_web/views/stop_event_view_test.exs @@ -1,243 +1,245 @@ -defmodule ApiWeb.StopEventViewTest do - use ApiWeb.ConnCase - - # Bring render/3 and render_to_string/3 for testing custom views - import Phoenix.View - - alias Model.StopEvent - - @stop_event %StopEvent{ - id: "trip1-route1-v1-1", - vehicle_id: "v1", - start_date: ~D[2026-02-24], - trip_id: "trip1", - direction_id: 0, - route_id: "route1", - revenue: :REVENUE, - stop_id: "stop1", - stop_sequence: 1, - arrived: ~U[2026-02-24 15:28:06Z], - departed: ~U[2026-02-24 15:40:46Z] - } - - @trip %Model.Trip{ - id: "trip1", - route_id: "route1", - name: "Test Trip", - direction_id: 0, - service_id: "service1", - headsign: "Testination", - wheelchair_accessible: 1, - bikes_allowed: 1, - revenue: :REVENUE - } - - @stop %Model.Stop{ - id: "stop1", - name: "Test Stop", - latitude: 42.0, - longitude: -71.0, - wheelchair_boarding: 0, - location_type: 0 - } - - @route %Model.Route{ - id: "route1", - agency_id: "agency1", - color: "FF0000", - description: "Test Route", - sort_order: 1, - text_color: "FFFFFF", - line_id: "line1", - listed_route: true, - type: 3 - } - - @route_pattern %Model.RoutePattern{ - id: "route1-_-0" - } - - @vehicle %Model.Vehicle{ - id: "v1", - current_status: :IN_TRANSIT_TO, - updated_at: ~U[2026-02-24 15:30:00Z], - revenue: :REVENUE - } - - @schedule %Model.Schedule{ - direction_id: 0, - route_id: "route1", - service_id: "service1", - stop_sequence: 1, - stop_id: "stop1", - timepoint?: false, - trip_id: "trip1" - } - - setup %{conn: conn} do - conn = Phoenix.Controller.put_view(conn, ApiWeb.StopEventView) - {:ok, %{conn: conn}} - end - - test "renders stop event with all attributes", %{conn: conn} do - rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, conn: conn) - - assert rendered["data"]["type"] == "stop_event" - assert rendered["data"]["id"] == "trip1-route1-v1-1" - - assert rendered["data"]["attributes"] == %{ - "start_date" => ~D[2026-02-24], - "direction_id" => 0, - "revenue" => :REVENUE, - "stop_sequence" => 1, - "arrived" => "2026-02-24T15:28:06Z", - "departed" => "2026-02-24T15:40:46Z" - } - end - - test "renders stop event with nil arrived (first stop)", %{conn: conn} do - stop_event = %StopEvent{@stop_event | arrived: nil} - rendered = render(ApiWeb.StopEventView, "index.json-api", data: stop_event, conn: conn) - - assert rendered["data"]["attributes"]["arrived"] == nil - assert rendered["data"]["attributes"]["departed"] == "2026-02-24T15:40:46Z" - end - - test "renders stop event with nil departed (last or current stop)", %{conn: conn} do - stop_event = %StopEvent{@stop_event | departed: nil} - rendered = render(ApiWeb.StopEventView, "index.json-api", data: stop_event, conn: conn) - - assert rendered["data"]["attributes"]["arrived"] == "2026-02-24T15:28:06Z" - assert rendered["data"]["attributes"]["departed"] == nil - end - - test "does not include attributes when empty set is requested", %{conn: conn} do - # JSON:API sparse fieldsets: when client requests empty field list, - # no attributes are returned (only id, type, and relationships) - conn = assign(conn, :opts, %{fields: %{"stop_event" => []}}) - - rendered = - render(ApiWeb.StopEventView, "index.json-api", - data: @stop_event, - conn: conn, - opts: conn.assigns.opts - ) - - assert rendered["data"]["attributes"] == %{} - end - - describe "relationships" do - setup do - State.Trip.new_state([@trip]) - State.Stop.new_state([@stop]) - State.Route.new_state([@route]) - State.Vehicle.new_state([@vehicle]) - :ok +if Application.compile_env(:api_web, [:features, :stop_events_route], false) do + defmodule ApiWeb.StopEventViewTest do + use ApiWeb.ConnCase + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + alias Model.StopEvent + + @stop_event %StopEvent{ + id: "trip1-route1-v1-1", + vehicle_id: "v1", + start_date: ~D[2026-02-24], + trip_id: "trip1", + direction_id: 0, + route_id: "route1", + revenue: :REVENUE, + stop_id: "stop1", + stop_sequence: 1, + arrived: ~U[2026-02-24 15:28:06Z], + departed: ~U[2026-02-24 15:40:46Z] + } + + @trip %Model.Trip{ + id: "trip1", + route_id: "route1", + name: "Test Trip", + direction_id: 0, + service_id: "service1", + headsign: "Testination", + wheelchair_accessible: 1, + bikes_allowed: 1, + revenue: :REVENUE + } + + @stop %Model.Stop{ + id: "stop1", + name: "Test Stop", + latitude: 42.0, + longitude: -71.0, + wheelchair_boarding: 0, + location_type: 0 + } + + @route %Model.Route{ + id: "route1", + agency_id: "agency1", + color: "FF0000", + description: "Test Route", + sort_order: 1, + text_color: "FFFFFF", + line_id: "line1", + listed_route: true, + type: 3 + } + + @route_pattern %Model.RoutePattern{ + id: "route1-_-0" + } + + @vehicle %Model.Vehicle{ + id: "v1", + current_status: :IN_TRANSIT_TO, + updated_at: ~U[2026-02-24 15:30:00Z], + revenue: :REVENUE + } + + @schedule %Model.Schedule{ + direction_id: 0, + route_id: "route1", + service_id: "service1", + stop_sequence: 1, + stop_id: "stop1", + timepoint?: false, + trip_id: "trip1" + } + + setup %{conn: conn} do + conn = Phoenix.Controller.put_view(conn, ApiWeb.StopEventView) + {:ok, %{conn: conn}} end - test "includes all default relationships but no optional relationships", %{conn: conn} do - rendered = - render(ApiWeb.StopEventView, "index.json-api", - data: @stop_event, - conn: conn - ) + test "renders stop event with all attributes", %{conn: conn} do + rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, conn: conn) - relationships = rendered["data"]["relationships"] - assert relationships["trip"]["data"]["id"] == "trip1" - assert relationships["stop"]["data"]["id"] == "stop1" - assert relationships["route"]["data"]["id"] == "route1" - assert relationships["vehicle"]["data"]["id"] == "v1" - refute Map.has_key?(relationships, "schedule") + assert rendered["data"]["type"] == "stop_event" + assert rendered["data"]["id"] == "trip1-route1-v1-1" + + assert rendered["data"]["attributes"] == %{ + "start_date" => ~D[2026-02-24], + "direction_id" => 0, + "revenue" => :REVENUE, + "stop_sequence" => 1, + "arrived" => "2026-02-24T15:28:06Z", + "departed" => "2026-02-24T15:40:46Z" + } end - test "preloads schedules for multiple stop_events", %{conn: conn} do - State.RoutePattern.new_state([@route_pattern]) - - schedule2 = %Model.Schedule{ - direction_id: 0, - route_id: "route1", - service_id: "service1", - stop_sequence: 2, - stop_id: "stop1", - timepoint?: false, - trip_id: "trip1" - } - - State.Schedule.new_state([@schedule, schedule2]) - State.RoutesPatternsAtStop.update!() - - stop_event2 = %StopEvent{@stop_event | id: "trip1-route1-v1-2", stop_sequence: 2} - - conn = - %{conn | params: %{"include" => "schedule"}} - |> ApiWeb.ApiControllerHelpers.split_include([]) + test "renders stop event with nil arrived (first stop)", %{conn: conn} do + stop_event = %StopEvent{@stop_event | arrived: nil} + rendered = render(ApiWeb.StopEventView, "index.json-api", data: stop_event, conn: conn) - rendered = - render(ApiWeb.StopEventView, "index.json-api", - data: [@stop_event, stop_event2], - conn: conn - ) + assert rendered["data"]["attributes"]["arrived"] == nil + assert rendered["data"]["attributes"]["departed"] == "2026-02-24T15:40:46Z" + end - assert length(rendered["data"]) == 2 - # Verify schedules were bulk loaded via schedule_for_many - assert get_in(rendered, ["data", Access.at(0), "relationships", "schedule", "data", "id"]) == - "schedule-trip1-stop1-1" + test "renders stop event with nil departed (last or current stop)", %{conn: conn} do + stop_event = %StopEvent{@stop_event | departed: nil} + rendered = render(ApiWeb.StopEventView, "index.json-api", data: stop_event, conn: conn) - assert get_in(rendered, ["data", Access.at(1), "relationships", "schedule", "data", "id"]) == - "schedule-trip1-stop1-2" + assert rendered["data"]["attributes"]["arrived"] == "2026-02-24T15:28:06Z" + assert rendered["data"]["attributes"]["departed"] == nil end - test "includes schedule relationship plus all default relationships when requested", %{ - conn: conn - } do - State.RoutePattern.new_state([@route_pattern]) - State.Schedule.new_state([@schedule]) - State.RoutesPatternsAtStop.update!() - - conn = - %{conn | params: %{"include" => "schedule,trip,stop,route,vehicle"}} - |> ApiWeb.ApiControllerHelpers.split_include([]) + test "does not include attributes when empty set is requested", %{conn: conn} do + # JSON:API sparse fieldsets: when client requests empty field list, + # no attributes are returned (only id, type, and relationships) + conn = assign(conn, :opts, %{fields: %{"stop_event" => []}}) rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, - conn: conn + conn: conn, + opts: conn.assigns.opts ) - relationships = rendered["data"]["relationships"] - assert relationships["trip"]["data"]["id"] == "trip1" - assert relationships["stop"]["data"]["id"] == "stop1" - assert relationships["route"]["data"]["id"] == "route1" - assert relationships["vehicle"]["data"]["id"] == "v1" - assert relationships["schedule"]["data"]["id"] == "schedule-trip1-stop1-1" + assert rendered["data"]["attributes"] == %{} end - test "returns nil schedule when schedule does not exist", %{conn: conn} do - # Set up required state but no schedule - State.RoutePattern.new_state([@route_pattern]) - State.Schedule.new_state([]) - State.RoutesPatternsAtStop.update!() - - conn = - %{conn | params: %{"include" => "schedule"}} - |> ApiWeb.ApiControllerHelpers.split_include([]) - - rendered = - render(ApiWeb.StopEventView, "index.json-api", - data: @stop_event, - conn: conn - ) - - # Schedule relationship should be present but with nil data - assert get_in(rendered, ["data", "relationships", "schedule", "data"]) == nil + describe "relationships" do + setup do + State.Trip.new_state([@trip]) + State.Stop.new_state([@stop]) + State.Route.new_state([@route]) + State.Vehicle.new_state([@vehicle]) + :ok + end + + test "includes all default relationships but no optional relationships", %{conn: conn} do + rendered = + render(ApiWeb.StopEventView, "index.json-api", + data: @stop_event, + conn: conn + ) + + relationships = rendered["data"]["relationships"] + assert relationships["trip"]["data"]["id"] == "trip1" + assert relationships["stop"]["data"]["id"] == "stop1" + assert relationships["route"]["data"]["id"] == "route1" + assert relationships["vehicle"]["data"]["id"] == "v1" + refute Map.has_key?(relationships, "schedule") + end + + test "preloads schedules for multiple stop_events", %{conn: conn} do + State.RoutePattern.new_state([@route_pattern]) + + schedule2 = %Model.Schedule{ + direction_id: 0, + route_id: "route1", + service_id: "service1", + stop_sequence: 2, + stop_id: "stop1", + timepoint?: false, + trip_id: "trip1" + } + + State.Schedule.new_state([@schedule, schedule2]) + State.RoutesPatternsAtStop.update!() + + stop_event2 = %StopEvent{@stop_event | id: "trip1-route1-v1-2", stop_sequence: 2} + + conn = + %{conn | params: %{"include" => "schedule"}} + |> ApiWeb.ApiControllerHelpers.split_include([]) + + rendered = + render(ApiWeb.StopEventView, "index.json-api", + data: [@stop_event, stop_event2], + conn: conn + ) + + assert length(rendered["data"]) == 2 + # Verify schedules were bulk loaded via schedule_for_many + assert get_in(rendered, ["data", Access.at(0), "relationships", "schedule", "data", "id"]) == + "schedule-trip1-stop1-1" + + assert get_in(rendered, ["data", Access.at(1), "relationships", "schedule", "data", "id"]) == + "schedule-trip1-stop1-2" + end + + test "includes schedule relationship plus all default relationships when requested", %{ + conn: conn + } do + State.RoutePattern.new_state([@route_pattern]) + State.Schedule.new_state([@schedule]) + State.RoutesPatternsAtStop.update!() + + conn = + %{conn | params: %{"include" => "schedule,trip,stop,route,vehicle"}} + |> ApiWeb.ApiControllerHelpers.split_include([]) + + rendered = + render(ApiWeb.StopEventView, "index.json-api", + data: @stop_event, + conn: conn + ) + + relationships = rendered["data"]["relationships"] + assert relationships["trip"]["data"]["id"] == "trip1" + assert relationships["stop"]["data"]["id"] == "stop1" + assert relationships["route"]["data"]["id"] == "route1" + assert relationships["vehicle"]["data"]["id"] == "v1" + assert relationships["schedule"]["data"]["id"] == "schedule-trip1-stop1-1" + end + + test "returns nil schedule when schedule does not exist", %{conn: conn} do + # Set up required state but no schedule + State.RoutePattern.new_state([@route_pattern]) + State.Schedule.new_state([]) + State.RoutesPatternsAtStop.update!() + + conn = + %{conn | params: %{"include" => "schedule"}} + |> ApiWeb.ApiControllerHelpers.split_include([]) + + rendered = + render(ApiWeb.StopEventView, "index.json-api", + data: @stop_event, + conn: conn + ) + + # Schedule relationship should be present but with nil data + assert get_in(rendered, ["data", "relationships", "schedule", "data"]) == nil + end end - end - describe "location" do - test "returns the correct stop event location", %{conn: conn} do - rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, conn: conn) + describe "location" do + test "returns the correct stop event location", %{conn: conn} do + rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, conn: conn) - assert rendered["data"]["links"]["self"] =~ "/stop_events/trip1-route1-v1-1" + assert rendered["data"]["links"]["self"] =~ "/stop_events/trip1-route1-v1-1" + end end end end diff --git a/config/runtime.exs b/config/runtime.exs index 624b915b3..5b6d743f7 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -82,6 +82,14 @@ if is_prod? and is_release? do s3_object: System.fetch_env!("CR_CROWDING_S3_OBJECT"), source: System.fetch_env!("CR_CROWDING_SOURCE") + config :state_mediator, :stop_events, + enabled: System.get_env("STOP_EVENTS_FETCH", "false"), + s3_bucket: System.get_env("STOP_EVENTS_S3_BUCKET"), + s3_object: System.get_env("STOP_EVENTS_S3_OBJECT") + + config :api_web, :features, + stop_events_route: System.get_env("STOP_EVENTS_ROUTE", "false") + config :recaptcha, enabled: true, public_key: System.fetch_env!("RECAPTCHA_PUBLIC_KEY"),