prop/test/microwaveprop/rover_planning_test.exs
Graham McInitre 255c99cb36 fix: unwrap Repo.transaction return values for ecto_sql 3.14, fix sandbox deadlock in rover_planning tests
- accounts.ex: Repo.transaction in ecto_sql 3.14 wraps all return values in {:ok, ...},
  causing callers to receive {:ok, {:error, ...}} and {:ok, {:ok, ...}} instead of
  direct tuples. Unwrap in update_user_email and update_user_and_delete_all_tokens.
- rover_planning_test.exs: switch to async: false + Oban.Testing manual mode so
  RoverPathProfileWorker jobs don't deadlock on sandbox connections inside
  Repo.transaction. Introduced create_and_complete_mission!/2 and run_backfill!/1
  helpers that separate transaction lifecycle from worker execution.
2026-07-21 10:24:06 -05:00

187 lines
6.5 KiB
Elixir

defmodule Microwaveprop.RoverPlanningTest do
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Station
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Workers.RoverPathProfileWorker
setup do
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
# Creates a mission and runs the full reconcile + compute pipeline under
# manual Oban mode so that:
# 1. The transaction inside reconcile_mission_paths/1 completes before any
# profile worker executes (avoiding a deadlock where the transaction
# holds the single sandbox connection while Task.async_stream tasks
# spawned by PathCompute.compute also need the DB).
# 2. Each RoverPathProfileWorker runs individually via perform_job, which
# executes in the test process (the sandbox owner when shared: true).
defp create_and_complete_mission!(user, attrs) do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, mission} = RoverPlanning.create_mission(user, attrs)
mission
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|> RoverPlanning.reconcile_mission_paths()
mission
|> RoverPlanning.list_paths()
|> Enum.each(fn path ->
perform_job(RoverPathProfileWorker, %{
"mission_id" => mission.id,
"rover_location_id" => path.rover_location_id,
"station_id" => path.station_id,
"band_mhz" => path.band_mhz
})
end)
mission
end)
end
# Runs backfill_paths/0 under manual Oban mode, then performs each
# enqueued RoverPathProfileWorker job for the given mission so paths
# land at :complete.
defp run_backfill!(mission) do
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = RoverPlanning.backfill_paths()
mission
|> RoverPlanning.list_paths()
|> Enum.each(fn path ->
perform_job(RoverPathProfileWorker, %{
"mission_id" => mission.id,
"rover_location_id" => path.rover_location_id,
"station_id" => path.station_id,
"band_mhz" => path.band_mhz
})
end)
end)
end
defp create_rover_location(attrs) do
user = AccountsFixtures.user_fixture()
{:ok, loc} =
Rover.create_location(
user,
Map.merge(%{lat: 32.5, lon: -97.5, status: :good}, attrs)
)
loc
end
defp valid_attrs(extra \\ %{}) do
Map.merge(
%{
"name" => "Test mission",
"band_mhz" => 10_000,
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => %{
"0" => %{"input" => "EM12kp", "position" => 0}
}
},
extra
)
end
describe "create_mission/2" do
test "inserts a mission with stations and enqueues a path per (rover x station) pair" do
user = AccountsFixtures.user_fixture()
_ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
_off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})
mission = create_and_complete_mission!(user, valid_attrs())
assert mission.user_id == user.id
assert [station] = mission.stations
assert station.grid == "EM12KP"
assert is_float(station.lat)
assert is_float(station.lon)
paths = RoverPlanning.list_paths(mission)
assert length(paths) == 1
assert hd(paths).status == :complete
end
test "completed path caches the link-budget loss components" do
user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
mission = create_and_complete_mission!(user, valid_attrs())
[path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
assert is_float(path.result["free_space_loss_db"])
assert is_float(path.result["oxygen_loss_db"])
assert is_float(path.result["humidity_loss_db_baseline"])
assert is_float(path.result["total_baseline_loss_db"])
# Free-space loss for a ~140 km, 10 GHz path is ~155 dB; sanity
# check it's in the right order of magnitude.
assert path.result["free_space_loss_db"] > 100
assert path.result["free_space_loss_db"] < 200
end
test "completed path stores a propagation_score key (may be nil without scores file)" do
user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
mission = create_and_complete_mission!(user, valid_attrs())
[path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
assert Map.has_key?(path.result, "propagation_score")
end
test "backfill_paths/0 re-runs failed paths across all missions" do
user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
mission = create_and_complete_mission!(user, valid_attrs())
[path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
# Simulate a path that was stuck in :failed (e.g. transient
# elevation API error during the original computation).
{:ok, _} =
path
|> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil})
|> Repo.update()
run_backfill!(mission)
[reloaded] = RoverPlanning.list_paths(mission)
assert reloaded.status == :complete
assert map_size(reloaded.result) > 0
end
test "backfill_paths/0 inserts missing rows for under-populated missions" do
user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
mission = create_and_complete_mission!(user, valid_attrs())
# Simulate a half-broken mission: paths were never persisted (e.g.
# the original create_mission Multi succeeded but the Oban insert
# was lost due to a crash). Backfill must repopulate the matrix.
Repo.delete_all(Ecto.Query.from(p in RoverPlanning.Path, where: p.mission_id == ^mission.id))
assert RoverPlanning.list_paths(mission) == []
run_backfill!(mission)
assert [path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
end
end
end