feat(rover): user-scoped FixedStation context with ownership guard
This commit is contained in:
parent
21d3adfd2b
commit
18f04a4345
2 changed files with 239 additions and 0 deletions
139
lib/microwaveprop/rover.ex
Normal file
139
lib/microwaveprop/rover.ex
Normal 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
|
||||||
100
test/microwaveprop/rover_test.exs
Normal file
100
test/microwaveprop/rover_test.exs
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue