prop/test/microwaveprop/radio/contact_edit_test.exs
Graham McIntire ca07c0288d
feat(contacts): antenna height fields feed terrain analysis
Add optional height1_ft/height2_ft to contact schema, submit form, and
edit form (direct + suggest-edit paths). Heights flow through to the
elevation-profile terrain analysis so clearance and diffraction are
computed from the actual antenna height instead of a 10-ft default.
Editing heights resets terrain_status and re-enqueues
TerrainProfileWorker so the stored path analysis picks up the new
geometry.

Also fix mark_likely_duct/3: the pattern was grabbing the element
instead of the index, so no duct ever got flagged as the likely
propagation path even when extract_ducts returned a valid layer.
2026-04-18 15:48:14 -05:00

405 lines
12 KiB
Elixir

defmodule Microwaveprop.Radio.ContactEditTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Accounts
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.IemClient
setup do
# Height edits auto-enqueue TerrainProfileWorker (which Oban runs
# inline in tests). Stub the external data sources so the job can
# complete without hitting the network.
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)
Req.Test.stub(HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
Req.Test.stub(IemClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
:ok
end
@contact_attrs %{
station1: "W5ISP",
station2: "K5TR",
qso_timestamp: ~U[2026-04-01 14:00:00Z],
grid1: "EM13",
grid2: "EL29",
pos1: %{"lat" => 33.5, "lon" => -97.0},
pos2: %{"lat" => 29.5, "lon" => -98.5},
mode: "CW",
band: Decimal.new("10000"),
distance_km: Decimal.new("450")
}
@user_attrs %{
callsign: "W5TEST",
name: "Test User",
email: "test@example.com",
password: "testpassword123"
}
@admin_attrs %{
callsign: "N5ADM",
name: "Admin User",
email: "admin@example.com",
password: "adminpassword123"
}
defp create_contact(_) do
{:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Repo.insert()
%{contact: contact}
end
defp create_user(_) do
{:ok, user} = Accounts.register_user(@user_attrs)
%{user: user}
end
defp create_admin(_) do
{:ok, admin} = Accounts.register_user(@admin_attrs)
admin = admin |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
%{admin: admin}
end
describe "ContactEdit.changeset/2" do
setup [:create_contact, :create_user]
test "valid with proper proposed changes", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"grid1" => "EM13kk"}
})
assert changeset.valid?
end
test "rejects empty proposed_changes", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{}
})
refute changeset.valid?
assert "must contain at least one change" in errors_on(changeset).proposed_changes
end
test "rejects invalid fields in proposed_changes", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"distance_km" => 100}
})
refute changeset.valid?
assert Enum.any?(errors_on(changeset).proposed_changes, &String.contains?(&1, "invalid fields"))
end
test "validates callsign format", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"station1" => "bad call!"}
})
refute changeset.valid?
end
test "validates grid format", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"grid1" => "ZZZZZZ"}
})
refute changeset.valid?
end
test "validates band value", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"band" => 99_999}
})
refute changeset.valid?
end
test "accepts integer height1_ft and height2_ft", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"height1_ft" => 25, "height2_ft" => 60}
})
assert changeset.valid?
end
test "rejects non-integer height1_ft", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"height1_ft" => "tall"}
})
refute changeset.valid?
end
test "rejects out-of-range height2_ft", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"height2_ft" => 9999}
})
refute changeset.valid?
end
test "validates mode value", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"mode" => "INVALID"}
})
refute changeset.valid?
end
test "requires contact_id, user_id, proposed_changes" do
changeset = ContactEdit.changeset(%ContactEdit{}, %{})
assert %{
contact_id: ["can't be blank"],
user_id: ["can't be blank"],
proposed_changes: ["can't be blank"]
} = errors_on(changeset)
end
end
describe "Radio.create_contact_edit/3" do
setup [:create_contact, :create_user]
test "creates a pending edit with only changed fields", %{contact: contact, user: user} do
proposed = %{"grid1" => "EM13kk", "station1" => "W5ISP"}
assert {:ok, edit} = Radio.create_contact_edit(contact, user, proposed)
assert edit.status == :pending
assert edit.proposed_changes == %{"grid1" => "EM13KK"}
assert edit.contact_id == contact.id
assert edit.user_id == user.id
end
test "rejects when nothing actually changed", %{contact: contact, user: user} do
proposed = %{"station1" => "W5ISP", "mode" => "CW"}
assert {:error, changeset} = Radio.create_contact_edit(contact, user, proposed)
assert "must contain at least one change" in errors_on(changeset).proposed_changes
end
end
describe "Radio.list_pending_edits/0" do
setup [:create_contact, :create_user]
test "returns pending edits", %{contact: contact, user: user} do
{:ok, _e1} =
Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
{:ok, _e2} =
Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"})
edits = Radio.list_pending_edits()
assert length(edits) == 2
fields = Enum.flat_map(edits, &Map.keys(&1.proposed_changes))
assert "grid1" in fields
assert "grid2" in fields
end
end
describe "Radio.approve_edit/3" do
setup [:create_contact, :create_user, :create_admin]
test "applies changes to the contact and marks approved", %{
contact: contact,
user: user,
admin: admin
} do
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
assert {:ok, approved_edit} = Radio.approve_edit(edit, admin, "Confirmed via LOTW")
assert approved_edit.status == :approved
assert approved_edit.admin_note == "Confirmed via LOTW"
assert approved_edit.reviewed_by_id == admin.id
assert approved_edit.reviewed_at
updated_contact = Radio.get_contact!(contact.id)
assert updated_contact.grid1 == "EM13KK"
end
test "re-enqueues enrichment when grid changes", %{
contact: contact,
user: user,
admin: admin
} do
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM15ab"})
{:ok, _approved} = Radio.approve_edit(edit, admin, nil)
updated = Radio.get_contact!(contact.id)
assert updated.grid1 == "EM15AB"
assert updated.pos1 != contact.pos1
end
end
describe "Radio.reject_edit/3" do
setup [:create_contact, :create_user, :create_admin]
test "marks edit as rejected without changing contact", %{
contact: contact,
user: user,
admin: admin
} do
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
assert {:ok, rejected_edit} = Radio.reject_edit(edit, admin, "Original data correct")
assert rejected_edit.status == :rejected
assert rejected_edit.admin_note == "Original data correct"
unchanged_contact = Radio.get_contact!(contact.id)
assert unchanged_contact.grid1 == contact.grid1
end
end
describe "Radio.pending_edit_count/0" do
setup [:create_contact, :create_user]
test "counts only pending edits", %{contact: contact, user: user} do
{:ok, _} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
{:ok, _} = Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"})
assert Radio.pending_edit_count() == 2
end
end
describe "Radio.apply_owner_edit/3" do
setup [:create_user, :create_admin]
defp owned_contact(user) do
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.put(@contact_attrs, :user_id, user.id))
|> Microwaveprop.Repo.insert()
contact
end
test "owner can apply edits directly without going through review", %{user: user} do
contact = owned_contact(user)
assert {:ok, updated} =
Radio.apply_owner_edit(contact, user, %{"grid1" => "EM15ab"})
assert updated.grid1 == "EM15AB"
# No pending edits were created
assert Radio.pending_edit_for_user(contact.id, user.id) == nil
end
test "re-enqueues enrichment when owner changes a grid", %{user: user} do
contact = owned_contact(user)
{:ok, updated} = Radio.apply_owner_edit(contact, user, %{"grid1" => "EM15ab"})
assert updated.pos1 != contact.pos1
assert updated.hrrr_status == :pending
assert updated.terrain_status == :pending
end
test "returns :not_owner error when user did not submit the contact", %{user: user} do
{:ok, other} = Accounts.register_user(%{@user_attrs | email: "other@example.com", callsign: "W5OTH"})
contact = owned_contact(other)
assert {:error, :not_owner} = Radio.apply_owner_edit(contact, user, %{"grid1" => "EM15ab"})
unchanged = Radio.get_contact!(contact.id)
assert unchanged.grid1 == contact.grid1
end
test "returns :not_owner on anonymous contacts (user_id is nil)", %{user: user} do
{:ok, anon} = %Contact{} |> Contact.changeset(@contact_attrs) |> Microwaveprop.Repo.insert()
assert {:error, :not_owner} = Radio.apply_owner_edit(anon, user, %{"grid1" => "EM15ab"})
end
test "returns error tuple when no changes are actually proposed", %{user: user} do
contact = owned_contact(user)
assert {:error, :no_changes} =
Radio.apply_owner_edit(contact, user, %{"station1" => contact.station1})
end
test "owner can set height1_ft and height2_ft", %{user: user} do
contact = owned_contact(user)
assert {:ok, updated} =
Radio.apply_owner_edit(contact, user, %{"height1_ft" => 20, "height2_ft" => 75})
assert updated.height1_ft == 20
assert updated.height2_ft == 75
end
test "height change resets terrain_status so terrain is re-analyzed", %{user: user} do
contact = owned_contact(user)
contact = contact |> Ecto.Changeset.change(terrain_status: :complete) |> Repo.update!()
{:ok, updated} = Radio.apply_owner_edit(contact, user, %{"height1_ft" => 30})
assert updated.height1_ft == 30
assert updated.terrain_status == :pending
end
end
describe "Radio.owner?/2" do
setup [:create_user]
test "true when contact.user_id matches the user", %{user: user} do
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.put(@contact_attrs, :user_id, user.id))
|> Microwaveprop.Repo.insert()
assert Radio.owner?(contact, user)
end
test "false when contact.user_id is nil", %{user: user} do
{:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Microwaveprop.Repo.insert()
refute Radio.owner?(contact, user)
end
test "false when user is nil" do
{:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Microwaveprop.Repo.insert()
refute Radio.owner?(contact, nil)
end
end
end