prop/test/microwaveprop/radio/contact_edit_test.exs
Graham McIntire a66d3094ca Add contact edit approval system with admin review queue
Registered users can suggest edits to any contact's core fields
(callsigns, grids, band, mode, timestamp). Edits enter an admin
approval queue with field-by-field diff view. On approve, changes
are applied and enrichment re-enqueued if grids/band changed.
Users receive email notification on approve or reject.

Also updates dependabot.yml for mix ecosystem.
2026-04-11 16:15:49 -05:00

248 lines
7.4 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
@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 "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
end