prop/test/microwaveprop/rover_planning_test.exs
Graham McIntire c2a23d1840
feat(rover-planning): hourly backfill worker recovers stuck paths
Adds RoverPlanning.backfill_paths/0 that walks every mission and
re-enqueues RoverPathProfileWorker jobs for any (rover × station) pair
whose Path is missing or not :complete. The path-profile worker
short-circuits on :complete, so this is idempotent.

Wires a new RoverMissionBackfillWorker into both dev (config.exs) and
prod (runtime.exs) crons at HH:30 — heals missions stuck in :pending
or :failed from transient elevation-API errors or interrupted
create_mission enqueues without operator action.
2026-05-03 13:52:27 -05:00

200 lines
7.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.RoverPlanningTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.Terrain.ElevationClient
setup do
# Oban runs inline in test, so RoverPathProfileWorker fires immediately
# when the context enqueues jobs. Stub elevation lookups so the worker
# can complete without hitting the real API.
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
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 × 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})
assert {:ok, mission} = RoverPlanning.create_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)
# only_known_good=true → only the :good location pairs with the station.
# Oban runs inline in test so the worker has already completed.
assert length(paths) == 1
assert hd(paths).status in [:complete, :failed]
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})
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
[path] = RoverPlanning.list_paths(mission)
# The worker is the gate: status only flips to :complete after the
# path-calculator pipeline (terrain + propagation lookup) has run.
# The score itself is nil in the test env (no .prop file on disk),
# but the key MUST be present so the show-page can render "—"
# without a key-existence check at every cell.
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})
assert {:ok, mission} = RoverPlanning.create_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()
assert :ok = RoverPlanning.backfill_paths()
# Oban inline → backfill enqueues a job, the worker reruns
# immediately, and the path flips back to :complete.
[reloaded] = RoverPlanning.list_paths(mission)
assert reloaded.status == :complete
assert is_map(reloaded.result)
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})
assert {:ok, mission} = RoverPlanning.create_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) == []
assert :ok = RoverPlanning.backfill_paths()
assert [path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
end
test "with only_known_good=false, all rover-locations get a path entry" do
user = AccountsFixtures.user_fixture()
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
_off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})
attrs = valid_attrs(%{"only_known_good" => false})
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
assert length(RoverPlanning.list_paths(mission)) == 2
end
test "missing stations returns a changeset error" do
user = AccountsFixtures.user_fixture()
attrs = Map.delete(valid_attrs(), "stations")
assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
refute cs.valid?
assert {_, _} = cs.errors[:stations] || hd(cs.errors)
end
end
describe "update_mission/3" do
test "owner can update a mission and paths get rebuilt" do
user = AccountsFixtures.user_fixture()
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
[original] = RoverPlanning.list_paths(mission)
attrs = valid_attrs(%{"name" => "Updated"})
assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
assert updated.name == "Updated"
# Old path was deleted by replace, fresh row inserted with new id.
[fresh] = RoverPlanning.list_paths(updated)
refute fresh.id == original.id
end
test "non-owner update is denied" do
owner = AccountsFixtures.user_fixture()
other = AccountsFixtures.user_fixture()
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
assert {:error, :not_found} =
RoverPlanning.update_mission(other, mission.id, valid_attrs())
end
test "admin can update any mission" do
owner = AccountsFixtures.user_fixture()
admin = AccountsFixtures.user_fixture()
{:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
assert {:ok, updated} =
RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))
assert updated.name == "By admin"
end
end
describe "delete_mission/2" do
test "owner can delete; non-owner cannot" do
owner = AccountsFixtures.user_fixture()
other = AccountsFixtures.user_fixture()
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
refute Repo.get(Mission, mission.id)
end
end
end