feat(rover-locations): add /rover-locations page with CRUD
New globally-shared list of rover-friendly (or off-limits) parking
spots. Anyone can view; logged-in users can add new locations and
edit/delete their own. Each location stores lat/lon, free-text notes,
and a status enum (:ideal | :off_limits).
* Microwaveprop.Rover.Location schema + migration
* Rover.list_locations/0, create_location/2, update_location/3,
delete_location/2 (mutations require User; ownership-scoped)
* /rover-locations LiveView in the public live_session — Add button
is disabled for anonymous visitors, Edit/Delete buttons render
only on the user's own entries
* Tests for context + LiveView covering anonymous read, owner-only
edit/delete, and form submission
This commit is contained in:
parent
ac98d35330
commit
140351c0f6
7 changed files with 550 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.Rover do
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Rover.FixedStation
|
alias Microwaveprop.Rover.FixedStation
|
||||||
|
alias Microwaveprop.Rover.Location
|
||||||
|
|
||||||
@default_stations [
|
@default_stations [
|
||||||
%{callsign: "W5LUA", grid: "EM13qc"},
|
%{callsign: "W5LUA", grid: "EM13qc"},
|
||||||
|
|
@ -123,6 +124,57 @@ defmodule Microwaveprop.Rover do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Rover locations (globally visible, logged-in to mutate) ──────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns every rover location across all users, newest first.
|
||||||
|
Anonymous visitors can read this list; mutations require a User.
|
||||||
|
"""
|
||||||
|
@spec list_locations() :: [Location.t()]
|
||||||
|
def list_locations do
|
||||||
|
Location
|
||||||
|
|> order_by([l], desc: l.inserted_at)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec create_location(User.t(), map()) ::
|
||||||
|
{:ok, Location.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def create_location(%User{} = user, attrs) do
|
||||||
|
%Location{user_id: user.id}
|
||||||
|
|> Location.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec update_location(User.t(), Ecto.UUID.t(), map()) ::
|
||||||
|
{:ok, Location.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
|
def update_location(%User{} = user, id, attrs) do
|
||||||
|
case fetch_owned_location(user, id) do
|
||||||
|
{:ok, location} ->
|
||||||
|
location
|
||||||
|
|> Location.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete_location(User.t(), Ecto.UUID.t()) ::
|
||||||
|
{:ok, Location.t()} | {:error, :not_found}
|
||||||
|
def delete_location(%User{} = user, id) do
|
||||||
|
case fetch_owned_location(user, id) do
|
||||||
|
{:ok, location} -> Repo.delete(location)
|
||||||
|
{:error, :not_found} -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_owned_location(%User{id: user_id}, id) do
|
||||||
|
case Repo.get(Location, id) do
|
||||||
|
%Location{user_id: ^user_id} = loc -> {:ok, loc}
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp next_position(%User{id: user_id}) do
|
defp next_position(%User{id: user_id}) do
|
||||||
query =
|
query =
|
||||||
from s in FixedStation,
|
from s in FixedStation,
|
||||||
|
|
|
||||||
44
lib/microwaveprop/rover/location.ex
Normal file
44
lib/microwaveprop/rover/location.ex
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
defmodule Microwaveprop.Rover.Location do
|
||||||
|
@moduledoc """
|
||||||
|
A globally-visible rover parking location contributed by a logged-in
|
||||||
|
user. Everyone can view the list; only authenticated users can create
|
||||||
|
entries (creator tracked via `user_id`).
|
||||||
|
|
||||||
|
`status` is `:ideal` (recommended spot) or `:off_limits` (avoid —
|
||||||
|
trespass, no-go, etc.).
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@statuses [:ideal, :off_limits]
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
schema "rover_locations" do
|
||||||
|
field :lat, :float
|
||||||
|
field :lon, :float
|
||||||
|
field :status, Ecto.Enum, values: @statuses, default: :ideal
|
||||||
|
field :notes, :string
|
||||||
|
|
||||||
|
belongs_to :user, Microwaveprop.Accounts.User
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@spec statuses() :: [atom()]
|
||||||
|
def statuses, do: @statuses
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
|
def changeset(location, attrs) do
|
||||||
|
location
|
||||||
|
|> cast(attrs, [:lat, :lon, :status, :notes])
|
||||||
|
|> validate_required([:lat, :lon, :status])
|
||||||
|
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
||||||
|
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
||||||
|
|> validate_inclusion(:status, @statuses)
|
||||||
|
|> validate_length(:notes, max: 4000)
|
||||||
|
end
|
||||||
|
end
|
||||||
279
lib/microwaveprop_web/live/rover_locations_live.ex
Normal file
279
lib/microwaveprop_web/live/rover_locations_live.ex
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverLocationsLive do
|
||||||
|
@moduledoc """
|
||||||
|
Globally-shared list of rover-friendly (or off-limits) parking
|
||||||
|
locations. Everyone sees the list; only logged-in users can add or
|
||||||
|
edit entries (and only the creator can edit/delete their own).
|
||||||
|
"""
|
||||||
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.Rover
|
||||||
|
alias Microwaveprop.Rover.Location
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
assign(socket,
|
||||||
|
page_title: "Rover Locations",
|
||||||
|
locations: Rover.list_locations(),
|
||||||
|
form: nil,
|
||||||
|
editing_id: nil
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("new", _params, socket) do
|
||||||
|
if user = current_user(socket) do
|
||||||
|
form = blank_form(user)
|
||||||
|
{:noreply, assign(socket, form: form, editing_id: nil)}
|
||||||
|
else
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in to add a location.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("edit", %{"id" => id}, socket) do
|
||||||
|
case current_user(socket) do
|
||||||
|
%User{id: user_id} ->
|
||||||
|
case Enum.find(socket.assigns.locations, &(&1.id == id)) do
|
||||||
|
%Location{user_id: ^user_id} = loc ->
|
||||||
|
cs = Location.changeset(loc, %{})
|
||||||
|
{:noreply, assign(socket, form: to_form(cs), editing_id: id)}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in to edit a location.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("cancel", _, socket) do
|
||||||
|
{:noreply, assign(socket, form: nil, editing_id: nil)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("validate", %{"location" => params}, socket) do
|
||||||
|
base = if socket.assigns.editing_id, do: location_for_edit(socket), else: %Location{}
|
||||||
|
cs = base |> Location.changeset(params) |> Map.put(:action, :validate)
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"location" => params}, socket) do
|
||||||
|
user = current_user(socket)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(user) ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
||||||
|
|
||||||
|
socket.assigns.editing_id ->
|
||||||
|
save_update(socket, user, params)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
save_create(socket, user, params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
case current_user(socket) do
|
||||||
|
%User{} = user ->
|
||||||
|
case Rover.delete_location(user, id) do
|
||||||
|
{:ok, _} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(locations: Rover.list_locations())
|
||||||
|
|> put_flash(:info, "Location removed.")}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "You can only delete your own locations.")}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_create(socket, user, params) do
|
||||||
|
case Rover.create_location(user, params) do
|
||||||
|
{:ok, _loc} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(form: nil, editing_id: nil, locations: Rover.list_locations())
|
||||||
|
|> put_flash(:info, "Location added.")}
|
||||||
|
|
||||||
|
{:error, cs} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_update(socket, user, params) do
|
||||||
|
case Rover.update_location(user, socket.assigns.editing_id, params) do
|
||||||
|
{:ok, _loc} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(form: nil, editing_id: nil, locations: Rover.list_locations())
|
||||||
|
|> put_flash(:info, "Location updated.")}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
||||||
|
|
||||||
|
{:error, cs} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp location_for_edit(socket) do
|
||||||
|
Enum.find(socket.assigns.locations, &(&1.id == socket.assigns.editing_id)) || %Location{}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blank_form(_user) do
|
||||||
|
%Location{} |> Location.changeset(%{}) |> to_form()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
|
||||||
|
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
|
||||||
|
|
||||||
|
defp scope_user(assigns) do
|
||||||
|
case assigns[:current_scope] do
|
||||||
|
%{user: %User{} = user} -> user
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_edit?(_assigns, %Location{user_id: nil}), do: false
|
||||||
|
|
||||||
|
defp can_edit?(assigns, %Location{user_id: uid}) do
|
||||||
|
case current_user(assigns) do
|
||||||
|
%User{id: ^uid} -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_label(:ideal), do: "Ideal"
|
||||||
|
defp status_label(:off_limits), do: "Off Limits"
|
||||||
|
|
||||||
|
defp status_class(:ideal), do: "badge badge-success"
|
||||||
|
defp status_class(:off_limits), do: "badge badge-error"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Rover Locations</h1>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
Community-shared parking spots for portable rover ops.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="new"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
disabled={is_nil(current_user(assigns))}
|
||||||
|
>
|
||||||
|
+ Add location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
|
||||||
|
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to add or edit locations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div :if={@form} class="card bg-base-200 p-4 mb-4">
|
||||||
|
<h2 class="text-lg font-semibold mb-2">
|
||||||
|
{if @editing_id, do: "Edit location", else: "New location"}
|
||||||
|
</h2>
|
||||||
|
<.form for={@form} phx-change="validate" phx-submit="save" id="location-form">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<.input
|
||||||
|
field={@form[:lat]}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
label="Latitude"
|
||||||
|
placeholder="32.5"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:lon]}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
label="Longitude"
|
||||||
|
placeholder="-97.5"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:status]}
|
||||||
|
type="select"
|
||||||
|
label="Status"
|
||||||
|
options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.input
|
||||||
|
field={@form[:notes]}
|
||||||
|
type="textarea"
|
||||||
|
label="Notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Trail access, line-of-sight notes, contact info, etc."
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
{if @editing_id, do: "Save", else: "Add"}
|
||||||
|
</button>
|
||||||
|
<button type="button" phx-click="cancel" class="btn btn-ghost btn-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@locations == []} class="text-base-content/60 text-sm py-8 text-center">
|
||||||
|
No locations yet. Be the first to share one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul :if={@locations != []} class="space-y-3">
|
||||||
|
<li
|
||||||
|
:for={loc <- @locations}
|
||||||
|
id={"location-#{loc.id}"}
|
||||||
|
class="card bg-base-100 border border-base-300 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class={status_class(loc.status)}>{status_label(loc.status)}</span>
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{Float.round(loc.lat, 5)}, {Float.round(loc.lon, 5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p :if={loc.notes && loc.notes != ""} class="text-sm whitespace-pre-line">
|
||||||
|
{loc.notes}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-base-content/50 mt-1">
|
||||||
|
Added {Calendar.strftime(loc.inserted_at, "%Y-%m-%d")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div :if={can_edit?(assigns, loc)} class="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="edit"
|
||||||
|
phx-value-id={loc.id}
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={loc.id}
|
||||||
|
data-confirm="Delete this location?"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -193,6 +193,7 @@ defmodule MicrowavepropWeb.Router do
|
||||||
live "/eme", EmeLive
|
live "/eme", EmeLive
|
||||||
live "/skewt", SkewtLive
|
live "/skewt", SkewtLive
|
||||||
live "/rover", RoverLive
|
live "/rover", RoverLive
|
||||||
|
live "/rover-locations", RoverLocationsLive
|
||||||
live "/algo", AlgoLive
|
live "/algo", AlgoLive
|
||||||
live "/about", AboutLive
|
live "/about", AboutLive
|
||||||
live "/privacy", PrivacyLive
|
live "/privacy", PrivacyLive
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.CreateRoverLocations do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:rover_locations, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true, null: false
|
||||||
|
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
|
||||||
|
add :lat, :float, null: false
|
||||||
|
add :lon, :float, null: false
|
||||||
|
add :status, :string, null: false, default: "ideal"
|
||||||
|
add :notes, :text
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:rover_locations, [:user_id])
|
||||||
|
create index(:rover_locations, [:status])
|
||||||
|
end
|
||||||
|
end
|
||||||
85
test/microwaveprop/rover/location_test.exs
Normal file
85
test/microwaveprop/rover/location_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
defmodule Microwaveprop.RoverLocationsTest do
|
||||||
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Rover
|
||||||
|
alias Microwaveprop.Rover.Location
|
||||||
|
|
||||||
|
defp user_fixture do
|
||||||
|
Microwaveprop.AccountsFixtures.user_fixture()
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_locations/0" do
|
||||||
|
test "returns all locations across all users, newest first" do
|
||||||
|
a = user_fixture()
|
||||||
|
b = user_fixture()
|
||||||
|
|
||||||
|
{:ok, _l1} = Rover.create_location(a, %{lat: 32.5, lon: -97.5, status: :ideal})
|
||||||
|
{:ok, _l2} = Rover.create_location(b, %{lat: 33.0, lon: -96.5, status: :off_limits})
|
||||||
|
|
||||||
|
result = Rover.list_locations()
|
||||||
|
assert length(result) == 2
|
||||||
|
assert Enum.all?(result, &match?(%Location{}, &1))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_location/2" do
|
||||||
|
test "stores the creator's user_id" do
|
||||||
|
user = user_fixture()
|
||||||
|
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :ideal})
|
||||||
|
assert loc.user_id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "supports notes and the off_limits status" do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
{:ok, loc} =
|
||||||
|
Rover.create_location(user, %{
|
||||||
|
lat: 32.0,
|
||||||
|
lon: -97.0,
|
||||||
|
status: :off_limits,
|
||||||
|
notes: "private property"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert loc.status == :off_limits
|
||||||
|
assert loc.notes == "private property"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects out-of-range coordinates" do
|
||||||
|
user = user_fixture()
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Rover.create_location(user, %{lat: 100.0, lon: 0.0, status: :ideal})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires lat and lon" do
|
||||||
|
user = user_fixture()
|
||||||
|
assert {:error, cs} = Rover.create_location(user, %{status: :ideal})
|
||||||
|
assert "can't be blank" in errors_on(cs).lat
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_location/3 and delete_location/2" do
|
||||||
|
test "creator can update their location" do
|
||||||
|
user = user_fixture()
|
||||||
|
{:ok, loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
|
||||||
|
assert {:ok, updated} = Rover.update_location(user, loc.id, %{notes: "updated"})
|
||||||
|
assert updated.notes == "updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-creator cannot update or delete" do
|
||||||
|
user = user_fixture()
|
||||||
|
other = user_fixture()
|
||||||
|
{:ok, loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
|
||||||
|
assert {:error, :not_found} = Rover.update_location(other, loc.id, %{notes: "x"})
|
||||||
|
assert {:error, :not_found} = Rover.delete_location(other, loc.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creator can delete their location" do
|
||||||
|
user = user_fixture()
|
||||||
|
{:ok, loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
|
||||||
|
assert {:ok, _} = Rover.delete_location(user, loc.id)
|
||||||
|
refute Enum.any?(Rover.list_locations(), &(&1.id == loc.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
71
test/microwaveprop_web/live/rover_locations_live_test.exs
Normal file
71
test/microwaveprop_web/live/rover_locations_live_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverLocationsLiveTest do
|
||||||
|
use MicrowavepropWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Microwaveprop.Rover
|
||||||
|
|
||||||
|
describe "anonymous visitor" do
|
||||||
|
test "can view the locations page", %{conn: conn} do
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-locations")
|
||||||
|
assert html =~ "Rover Locations"
|
||||||
|
assert html =~ "Sign in"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows existing locations from any user", %{conn: conn} do
|
||||||
|
user = Microwaveprop.AccountsFixtures.user_fixture()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Rover.create_location(user, %{
|
||||||
|
lat: 32.5,
|
||||||
|
lon: -97.5,
|
||||||
|
status: :ideal,
|
||||||
|
notes: "great hill"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-locations")
|
||||||
|
assert html =~ "Ideal"
|
||||||
|
assert html =~ "great hill"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Add button is disabled for anonymous users", %{conn: conn} do
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-locations")
|
||||||
|
assert html =~ ~s(disabled)
|
||||||
|
assert html =~ "Add location"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "logged-in user" do
|
||||||
|
setup :register_and_log_in_user
|
||||||
|
|
||||||
|
test "can add a new location", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/rover-locations")
|
||||||
|
|
||||||
|
lv |> element("button", "+ Add location") |> render_click()
|
||||||
|
|
||||||
|
lv
|
||||||
|
|> form("#location-form",
|
||||||
|
location: %{lat: "32.5", lon: "-97.5", status: "ideal", notes: "test note"}
|
||||||
|
)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
html = render(lv)
|
||||||
|
assert html =~ "test note"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can edit and delete only their own location", %{conn: conn, user: user} do
|
||||||
|
other = Microwaveprop.AccountsFixtures.user_fixture()
|
||||||
|
{:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
{:ok, theirs} = Rover.create_location(other, %{lat: 33.0, lon: -98.0, status: :off_limits})
|
||||||
|
|
||||||
|
{:ok, lv, html} = live(conn, ~p"/rover-locations")
|
||||||
|
|
||||||
|
assert html =~ "location-#{mine.id}"
|
||||||
|
assert html =~ "location-#{theirs.id}"
|
||||||
|
|
||||||
|
# Edit/Delete buttons rendered for owned location only.
|
||||||
|
assert has_element?(lv, "#location-#{mine.id} button", "Edit")
|
||||||
|
refute has_element?(lv, "#location-#{theirs.id} button", "Edit")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue