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