The LiveTable on /admin/contact-edits used the bare ContactEdit schema
as its data source, which caused three symptoms:
- '0 pending' counter but stale approved/rejected edits still visible
- Blank contact/submitted-by cells (select_columns stripped preloaded
associations, cell renderers received flat maps with no :contact/:user)
- Approve/reject didn't remove the row from the table
Fix: assign {Radio, :pending_edits_query, []} as the data_provider in
mount so handle_params threads it to stream_resources. The query variant
of list_resources preserves preloaded associations and includes the
WHERE status = :pending filter.
Added two tests that verify the table rendering and edit removal.
577 lines
19 KiB
Elixir
577 lines
19 KiB
Elixir
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
|
|
|
|
test "returns {:error, :contact_deleted} when the contact was removed before approval", %{
|
|
contact: contact,
|
|
user: user,
|
|
admin: admin
|
|
} do
|
|
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
|
|
|
|
# Delete the contact before approving — the FK cascade (on_delete:
|
|
# :delete_all) removes the edit row too.
|
|
Repo.delete!(contact)
|
|
|
|
assert {:error, :contact_deleted} = Radio.approve_edit(edit, admin, nil)
|
|
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{user_id: user.id}
|
|
|> Contact.changeset(@contact_attrs)
|
|
|> 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 "ignores non-editable keys instead of mass-assigning them", %{user: user} do
|
|
# Crafted form posting `user_id` / `flagged_invalid` / `inserted_at`
|
|
# alongside an allowed field. Only the allowed field should land
|
|
# on the contact; the rest must be silently dropped.
|
|
contact = owned_contact(user)
|
|
original_user_id = contact.user_id
|
|
original_inserted_at = contact.inserted_at
|
|
|
|
{:ok, other} =
|
|
Accounts.register_user(%{
|
|
email: "evil-edit-#{System.unique_integer([:positive])}@example.com",
|
|
password: "passwordpassword",
|
|
callsign: "K0EVL",
|
|
name: "Evil"
|
|
})
|
|
|
|
params = %{
|
|
"grid1" => "EM15ab",
|
|
"user_id" => other.id,
|
|
"flagged_invalid" => true,
|
|
"inserted_at" => "2000-01-01T00:00:00Z"
|
|
}
|
|
|
|
assert {:ok, updated} = Radio.apply_owner_edit(contact, user, params)
|
|
|
|
assert updated.grid1 == "EM15AB"
|
|
assert updated.user_id == original_user_id
|
|
assert updated.flagged_invalid == false
|
|
assert updated.inserted_at == original_inserted_at
|
|
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{user_id: user.id}
|
|
|> Contact.changeset(@contact_attrs)
|
|
|> 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
|