defmodule Microwaveprop.Radio.ContactEditTest do use Microwaveprop.DataCase, async: true alias Microwaveprop.Accounts alias Microwaveprop.Cache 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 "accepts boolean private flag", %{contact: contact, user: user} do changeset = ContactEdit.changeset(%ContactEdit{}, %{ contact_id: contact.id, user_id: user.id, proposed_changes: %{"private" => true} }) assert changeset.valid? end test "rejects non-boolean private", %{contact: contact, user: user} do changeset = ContactEdit.changeset(%ContactEdit{}, %{ contact_id: contact.id, user_id: user.id, proposed_changes: %{"private" => "yes"} }) 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 test "untouched form (qso_timestamp pre-fill string) is treated as no-op", %{user: user} do # The /contacts/:id edit form pre-fills qso_timestamp via # `Calendar.strftime(ts, "%Y-%m-%d %H:%M")`. When the user submits # without touching it, the diff used to compare a string against # the stored DateTime, treat it as a change, and then crash in # build_contact_changes on `DateTime.from_iso8601("2026-04-01 14:00")`. contact = owned_contact(user) pre_fill = Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M") params = %{ "station1" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "qso_timestamp" => pre_fill, "mode" => contact.mode } assert {:error, :no_changes} = Radio.apply_owner_edit(contact, user, params) end test "owner can edit qso_timestamp via the form's space-separated input", %{user: user} do contact = owned_contact(user) {:ok, updated} = Radio.apply_owner_edit(contact, user, %{"qso_timestamp" => "2026-04-01 15:30"}) assert updated.qso_timestamp == ~U[2026-04-01 15:30:00Z] end test "owner can edit qso_timestamp via ISO 8601 with seconds + Z", %{user: user} do contact = owned_contact(user) {:ok, updated} = Radio.apply_owner_edit(contact, user, %{"qso_timestamp" => "2026-04-01T15:30:00Z"}) assert updated.qso_timestamp == ~U[2026-04-01 15:30:00Z] end test "unparseable qso_timestamp drops the field instead of crashing", %{user: user} do contact = owned_contact(user) # Submitting just a bogus timestamp with no other fields means no # other changes to apply — apply_owner_edit returns :no_changes # rather than raising a MatchError on DateTime.from_iso8601. assert {:error, :no_changes} = Radio.apply_owner_edit(contact, user, %{"qso_timestamp" => "not a real date"}) end test "unchanged private checkbox is treated as no-op", %{user: user} do contact = owned_contact(user) # Schema default is `private: false`; the form always submits the # current state. Without a current_value/2 clause the diff sees # `false != nil` and treats it as a real change. assert contact.private == false assert {:error, :no_changes} = Radio.apply_owner_edit(contact, user, %{"private" => false}) end test "owner can flip private from false to true", %{user: user} do contact = owned_contact(user) {:ok, updated} = Radio.apply_owner_edit(contact, user, %{"private" => true}) assert updated.private == true end test "checkbox 'true'/'false' strings round-trip correctly", %{user: user} do contact = owned_contact(user) # Form delivers booleans as strings; the existing # normalize_boolean_field coerces them, but the diff still has to # use the boolean current_value for correctness. assert {:error, :no_changes} = Radio.apply_owner_edit(contact, user, %{"private" => "false"}) {:ok, updated} = Radio.apply_owner_edit(contact, user, %{"private" => "true"}) assert updated.private == true end test "applying an edit invalidates the public contact-map caches", %{user: user} do contact = owned_contact(user) # Pre-seed the same three cache keys the insert path invalidates. # If apply_edit_to_contact forgets to invalidate, /api/contacts/map # and /contacts/map keep serving stale (and for private flips, # privacy-leaking) data for up to 10 minutes. sentinel = :stale_payload ten_min = 10 * 60 * 1_000 Cache.put({Radio, :total_contact_count}, sentinel, ten_min) Cache.put({Radio, :contact_map_payload}, sentinel, ten_min) Cache.put({MicrowavepropWeb.ContactMapController, :gzipped_payload}, sentinel, ten_min) {:ok, _} = Radio.apply_owner_edit(contact, user, %{"private" => true}) assert :ets.lookup(:microwaveprop_cache, {Radio, :total_contact_count}) == [] assert :ets.lookup(:microwaveprop_cache, {Radio, :contact_map_payload}) == [] assert :ets.lookup(:microwaveprop_cache, {MicrowavepropWeb.ContactMapController, :gzipped_payload}) == [] 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