feat(rover): user-scoped FixedStation context with ownership guard

This commit is contained in:
Graham McIntire 2026-04-25 16:15:17 -05:00
parent 21d3adfd2b
commit 18f04a4345
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 239 additions and 0 deletions

139
lib/microwaveprop/rover.ex Normal file
View file

@ -0,0 +1,139 @@
defmodule Microwaveprop.Rover do
@moduledoc """
Context for managing a user's fixed stations and providing the
rover-planning defaults.
All mutations are scoped to a user `update_station/3`, `delete_station/2`,
and `toggle_selected/2` return `{:error, :not_found}` when invoked on
a station belonging to a different user.
"""
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover.FixedStation
@default_stations [
%{callsign: "W5LUA", grid: "EM13qc"},
%{callsign: "W5HN", grid: "EM13nf"},
%{callsign: "N5XU", grid: "EM10cl"}
]
@spec list_stations(User.t()) :: [FixedStation.t()]
def list_stations(%User{id: user_id}) do
FixedStation
|> where([s], s.user_id == ^user_id)
|> order_by([s], asc: s.position, asc: s.inserted_at)
|> Repo.all()
end
@spec create_station(User.t(), map()) :: {:ok, FixedStation.t()} | {:error, Ecto.Changeset.t()}
def create_station(%User{} = user, attrs) do
attrs_with_position = Map.put_new(attrs, :position, next_position(user))
%FixedStation{user_id: user.id}
|> FixedStation.changeset(attrs_with_position)
|> Repo.insert()
|> maybe_enqueue_elevation()
end
@spec update_station(User.t(), Ecto.UUID.t(), map()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_station(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
{:ok, station} ->
station
|> FixedStation.changeset(attrs)
|> Repo.update()
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec delete_station(User.t(), Ecto.UUID.t()) ::
{:ok, FixedStation.t()} | {:error, :not_found}
def delete_station(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, station} ->
Repo.delete(station)
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec toggle_selected(User.t(), Ecto.UUID.t()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def toggle_selected(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, station} ->
station
|> Ecto.Changeset.change(selected: not station.selected)
|> Repo.update()
{:error, :not_found} ->
{:error, :not_found}
end
end
@doc """
Three hardcoded NTMS stations used as the anonymous-user fallback in
the rover planner. Plain maps shaped like `FixedStation`, with lat/lon
derived from each grid via `Maidenhead.to_latlon/1`.
"""
@spec default_stations() :: [map()]
def default_stations do
@default_stations
|> Enum.with_index()
|> Enum.map(fn {%{callsign: callsign, grid: grid}, index} ->
{:ok, {lat, lon}} = Maidenhead.to_latlon(grid)
%{
callsign: callsign,
grid: grid,
lat: lat,
lon: lon,
elevation_m: nil,
selected: true,
position: index
}
end)
end
defp fetch_owned(%User{id: user_id}, id) do
case Repo.get(FixedStation, id) do
%FixedStation{user_id: ^user_id} = station -> {:ok, station}
_ -> {:error, :not_found}
end
end
defp next_position(%User{id: user_id}) do
query =
from s in FixedStation,
where: s.user_id == ^user_id,
select: count(s.id)
Repo.one(query) || 0
end
defp maybe_enqueue_elevation({:ok, %FixedStation{elevation_m: nil, id: id}} = result) do
enqueue_station_elevation(id)
result
end
defp maybe_enqueue_elevation(other), do: other
# Wrapped behind a function-exported guard so this module compiles
# cleanly while Task 8's worker is still being authored — the worker
# is referenced by atom and resolved at runtime once it's loaded.
defp enqueue_station_elevation(id) do
worker = Microwaveprop.Workers.StationElevationWorker
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
%{id: id} |> worker.new() |> Oban.insert()
end
end
end

View file

@ -0,0 +1,100 @@
defmodule Microwaveprop.RoverTest do
use Microwaveprop.DataCase, async: true
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Rover
alias Microwaveprop.Rover.FixedStation
describe "list_stations/1" do
test "returns user's stations ordered by position asc" do
user = user_fixture()
{:ok, _b} = Rover.create_station(user, %{callsign: "B5BBB", lat: 33.0, lon: -96.0})
{:ok, _a} = Rover.create_station(user, %{callsign: "A5AAA", lat: 33.0, lon: -96.0})
stations = Rover.list_stations(user)
assert Enum.map(stations, & &1.callsign) == ["B5BBB", "A5AAA"]
assert Enum.map(stations, & &1.position) == [0, 1]
end
end
describe "create_station/2" do
test "derives lat/lon from grid only" do
user = user_fixture()
{:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", grid: "EM13qc"})
assert_in_delta station.lat, 33.10, 0.05
assert_in_delta station.lon, -96.625, 0.05
end
test "errors with neither grid nor lat/lon" do
user = user_fixture()
assert {:error, %Ecto.Changeset{}} = Rover.create_station(user, %{callsign: "W5LUA"})
end
test "unique constraint surfaces as changeset error" do
user = user_fixture()
{:ok, _} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0})
assert {:error, %Ecto.Changeset{}} =
Rover.create_station(user, %{callsign: "W5LUA", lat: 34.0, lon: -97.0})
end
end
describe "update_station/3" do
test "foreign user returns :not_found" do
user = user_fixture()
other = user_fixture()
{:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0})
assert {:error, :not_found} = Rover.update_station(other, station.id, %{selected: false})
end
end
describe "delete_station/2" do
test "foreign user returns :not_found" do
user = user_fixture()
other = user_fixture()
{:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0})
assert {:error, :not_found} = Rover.delete_station(other, station.id)
end
test "owner deletes" do
user = user_fixture()
{:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0})
assert {:ok, %FixedStation{}} = Rover.delete_station(user, station.id)
assert Rover.list_stations(user) == []
end
end
describe "toggle_selected/2" do
test "flips boolean" do
user = user_fixture()
{:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0})
assert station.selected == true
assert {:ok, updated} = Rover.toggle_selected(user, station.id)
assert updated.selected == false
assert {:ok, again} = Rover.toggle_selected(user, station.id)
assert again.selected == true
end
end
describe "default_stations/0" do
test "returns 3 hardcoded NTMS stations" do
stations = Rover.default_stations()
assert length(stations) == 3
callsigns = Enum.map(stations, & &1.callsign)
assert "W5LUA" in callsigns
assert "W5HN" in callsigns
assert "N5XU" in callsigns
assert Enum.all?(stations, & &1.selected)
assert Enum.map(stations, & &1.position) == [0, 1, 2]
Enum.each(stations, fn s ->
assert is_float(s.lat)
assert is_float(s.lon)
end)
end
end
end