- TOCTOU race: add partial unique index + rescue constraint violation on concurrent contact insert (radio.ex:737-768) - Mass assignment: remove :user_id from @optional_fields (contact.ex:85) - Stale path_live result: clear result when URL params change (path_live.ex:105) - Blocking migrations: wrap Release.migrate() in Task.start (application.ex:79) - Rate limiter: fix guard for monitor tokens (not is_nil vs is_binary) - findings.md: document remaining non-critical bugs and improvements
777 lines
25 KiB
Elixir
777 lines
25 KiB
Elixir
defmodule Microwaveprop.RadioTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
import Microwaveprop.AccountsFixtures
|
|
|
|
alias Microwaveprop.Accounts.Scope
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Radio.Contact
|
|
|
|
defp create_contact(attrs \\ %{}) do
|
|
default = %{
|
|
station1: "W5XD",
|
|
station2: "K5TR",
|
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
|
mode: "CW",
|
|
band: Decimal.new("1296"),
|
|
grid1: "EM12",
|
|
grid2: "EM00",
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
|
distance_km: Decimal.new("295")
|
|
}
|
|
|
|
merged = Map.merge(default, attrs)
|
|
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
|
|
|
|
{:ok, contact} =
|
|
%Contact{user_id: user_id}
|
|
|> Contact.changeset(changeset_attrs)
|
|
|> Repo.insert()
|
|
|
|
contact
|
|
end
|
|
|
|
describe "list_contacts/1" do
|
|
test "returns empty page when no QSOs exist" do
|
|
result = Radio.list_contacts()
|
|
|
|
assert result.entries == []
|
|
assert result.page == 1
|
|
assert result.total_pages == 1
|
|
assert result.total_entries == 0
|
|
end
|
|
|
|
test "paginates 20 per page, ordered by qso_timestamp desc" do
|
|
for i <- 1..25 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
result = Radio.list_contacts()
|
|
|
|
assert length(result.entries) == 20
|
|
assert result.page == 1
|
|
assert result.total_pages == 2
|
|
assert result.total_entries == 25
|
|
|
|
# Most recent first
|
|
first = hd(result.entries)
|
|
assert first.qso_timestamp == DateTime.add(~U[2026-01-01 00:00:00Z], 25 * 3600, :second)
|
|
end
|
|
|
|
test "returns second page" do
|
|
for i <- 1..25 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
result = Radio.list_contacts(page: 2)
|
|
|
|
assert length(result.entries) == 5
|
|
assert result.page == 2
|
|
assert result.total_pages == 2
|
|
end
|
|
|
|
test "honors per_page option" do
|
|
for i <- 1..25 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
result = Radio.list_contacts(per_page: 50)
|
|
|
|
assert length(result.entries) == 25
|
|
assert result.total_pages == 1
|
|
end
|
|
|
|
test "clamps per_page to the maximum" do
|
|
for i <- 1..3 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
# 5000 is well above the 200 cap; should still return all 3 rows.
|
|
result = Radio.list_contacts(per_page: 5000)
|
|
assert length(result.entries) == 3
|
|
end
|
|
|
|
test "falls back to default when per_page is invalid" do
|
|
for i <- 1..25 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
result = Radio.list_contacts(per_page: 0)
|
|
assert length(result.entries) == 20
|
|
end
|
|
end
|
|
|
|
describe "list_contacts/1 sorting" do
|
|
test "sorts by station1 ascending" do
|
|
create_contact(%{station1: "ZZ9ZZ"})
|
|
create_contact(%{station1: "AA1AA"})
|
|
|
|
result = Radio.list_contacts(sort_by: :station1, sort_order: :asc)
|
|
|
|
stations = Enum.map(result.entries, & &1.station1)
|
|
assert stations == ["AA1AA", "ZZ9ZZ"]
|
|
end
|
|
|
|
test "sorts by distance_km descending" do
|
|
create_contact(%{distance_km: Decimal.new("100"), station1: "A1A"})
|
|
create_contact(%{distance_km: Decimal.new("500"), station1: "B2B"})
|
|
|
|
result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc)
|
|
|
|
distances = Enum.map(result.entries, & &1.distance_km)
|
|
assert distances == [Decimal.new("500"), Decimal.new("100")]
|
|
end
|
|
|
|
test "invalid sort_by falls back to qso_timestamp desc" do
|
|
early = create_contact(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]})
|
|
late = create_contact(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]})
|
|
|
|
result = Radio.list_contacts(sort_by: :bogus)
|
|
|
|
ids = Enum.map(result.entries, & &1.id)
|
|
assert ids == [late.id, early.id]
|
|
end
|
|
end
|
|
|
|
describe "unprocessed_contacts/1" do
|
|
test "returns QSOs where weather_queued is false and pos1 is not nil" do
|
|
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
|
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
|
|
|
results = Radio.unprocessed_contacts()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert q1.id in ids
|
|
end
|
|
|
|
test "excludes QSOs already marked as weather_status" do
|
|
q = create_contact()
|
|
Radio.mark_weather_queued!([q.id])
|
|
|
|
assert Radio.unprocessed_contacts() == []
|
|
end
|
|
|
|
test "orders by qso_timestamp ascending" do
|
|
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
|
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
|
|
|
results = Radio.unprocessed_contacts()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert ids == [q_early.id, q_late.id]
|
|
end
|
|
|
|
test "respects limit parameter" do
|
|
for i <- 1..5 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
assert length(Radio.unprocessed_contacts(3)) == 3
|
|
end
|
|
end
|
|
|
|
describe "mark_weather_queued!/1" do
|
|
test "sets weather_queued to true for given IDs" do
|
|
q1 = create_contact(%{station1: "A1A"})
|
|
q2 = create_contact(%{station1: "B2B"})
|
|
|
|
Radio.mark_weather_queued!([q1.id, q2.id])
|
|
|
|
assert Repo.get!(Contact, q1.id).weather_status == :queued
|
|
assert Repo.get!(Contact, q2.id).weather_status == :queued
|
|
end
|
|
|
|
test "does not affect other QSOs" do
|
|
q1 = create_contact(%{station1: "A1A"})
|
|
q2 = create_contact(%{station1: "B2B"})
|
|
|
|
Radio.mark_weather_queued!([q1.id])
|
|
|
|
assert Repo.get!(Contact, q1.id).weather_status == :queued
|
|
assert Repo.get!(Contact, q2.id).weather_status == :pending
|
|
end
|
|
|
|
test "handles empty list" do
|
|
assert Radio.mark_weather_queued!([]) == {0, nil}
|
|
end
|
|
end
|
|
|
|
describe "haversine_km/4" do
|
|
test "calculates distance between two known points" do
|
|
# Dallas to Austin ~roughly 295 km
|
|
dist = Radio.haversine_km(32.9, -97.0, 30.3, -97.7)
|
|
assert_in_delta dist, 295, 5
|
|
end
|
|
|
|
test "returns 0 for same point" do
|
|
assert Radio.haversine_km(32.9, -97.0, 32.9, -97.0) == 0.0
|
|
end
|
|
|
|
test "uses the numerically stable atan2 form" do
|
|
# Antipodal points sit on a 6371-km-radius sphere at exactly
|
|
# π·r ≈ 20_015 km apart. Both haversine forms agree here, but
|
|
# the asin form is fragile when `a` rounds to slightly above 1
|
|
# (sqrt returns >1 and asin returns NaN). atan2 stays finite.
|
|
dist = Radio.haversine_km(0.0, 0.0, 0.0, 180.0)
|
|
assert_in_delta dist, 20_015, 1.0
|
|
end
|
|
end
|
|
|
|
describe "backfill_distances/1" do
|
|
test "calculates and stores distance for QSOs missing distance_km" do
|
|
contact = create_contact(%{distance_km: nil})
|
|
assert is_nil(contact.distance_km)
|
|
|
|
Radio.backfill_distances([contact])
|
|
|
|
updated = Repo.get!(Contact, contact.id)
|
|
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
|
|
end
|
|
|
|
test "skips QSOs that already have a distance" do
|
|
contact = create_contact(%{distance_km: Decimal.new("100")})
|
|
|
|
Radio.backfill_distances([contact])
|
|
|
|
updated = Repo.get!(Contact, contact.id)
|
|
assert updated.distance_km == Decimal.new("100")
|
|
end
|
|
|
|
test "skips QSOs missing pos2" do
|
|
contact = create_contact(%{distance_km: nil, pos2: nil})
|
|
|
|
Radio.backfill_distances([contact])
|
|
|
|
updated = Repo.get!(Contact, contact.id)
|
|
assert is_nil(updated.distance_km)
|
|
end
|
|
end
|
|
|
|
describe "get_contact!/1" do
|
|
test "returns a QSO by ID" do
|
|
contact = create_contact()
|
|
|
|
found = Radio.get_contact!(contact.id)
|
|
assert found.id == contact.id
|
|
assert found.station1 == "W5XD"
|
|
end
|
|
|
|
test "raises Ecto.NoResultsError on bad ID" do
|
|
assert_raise Ecto.NoResultsError, fn ->
|
|
Radio.get_contact!(Ecto.UUID.generate())
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "unprocessed_hrrr_contacts/1" do
|
|
test "returns QSOs where hrrr_queued is false and pos1 is not nil" do
|
|
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
|
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
|
|
|
results = Radio.unprocessed_hrrr_contacts()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert q1.id in ids
|
|
end
|
|
|
|
test "excludes QSOs already marked as hrrr_status" do
|
|
q = create_contact()
|
|
Radio.mark_hrrr_queued!([q.id])
|
|
|
|
assert Radio.unprocessed_hrrr_contacts() == []
|
|
end
|
|
|
|
test "orders by qso_timestamp ascending" do
|
|
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
|
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
|
|
|
results = Radio.unprocessed_hrrr_contacts()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert ids == [q_early.id, q_late.id]
|
|
end
|
|
|
|
test "respects limit parameter" do
|
|
for i <- 1..5 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
assert length(Radio.unprocessed_hrrr_contacts(3)) == 3
|
|
end
|
|
end
|
|
|
|
describe "mark_hrrr_queued!/1" do
|
|
test "sets hrrr_queued to true for given IDs" do
|
|
q1 = create_contact(%{station1: "A1A"})
|
|
q2 = create_contact(%{station1: "B2B"})
|
|
|
|
Radio.mark_hrrr_queued!([q1.id, q2.id])
|
|
|
|
assert Repo.get!(Contact, q1.id).hrrr_status == :queued
|
|
assert Repo.get!(Contact, q2.id).hrrr_status == :queued
|
|
end
|
|
|
|
test "does not affect other QSOs" do
|
|
q1 = create_contact(%{station1: "A1A"})
|
|
q2 = create_contact(%{station1: "B2B"})
|
|
|
|
Radio.mark_hrrr_queued!([q1.id])
|
|
|
|
assert Repo.get!(Contact, q1.id).hrrr_status == :queued
|
|
assert Repo.get!(Contact, q2.id).hrrr_status == :pending
|
|
end
|
|
|
|
test "handles empty list" do
|
|
assert Radio.mark_hrrr_queued!([]) == {0, nil}
|
|
end
|
|
end
|
|
|
|
describe "unprocessed_terrain_contacts/1" do
|
|
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
|
|
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
|
_q2 = create_contact(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
|
_q3 = create_contact(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
|
|
|
|
results = Radio.unprocessed_terrain_contacts()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert q1.id in ids
|
|
assert length(ids) == 1
|
|
end
|
|
|
|
test "excludes QSOs already marked as terrain_status" do
|
|
q = create_contact()
|
|
Radio.mark_terrain_queued!([q.id])
|
|
|
|
assert Radio.unprocessed_terrain_contacts() == []
|
|
end
|
|
|
|
test "orders by qso_timestamp ascending" do
|
|
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
|
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
|
|
|
results = Radio.unprocessed_terrain_contacts()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert ids == [q_early.id, q_late.id]
|
|
end
|
|
|
|
test "respects limit parameter" do
|
|
for i <- 1..5 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_contact(%{qso_timestamp: ts})
|
|
end
|
|
|
|
assert length(Radio.unprocessed_terrain_contacts(3)) == 3
|
|
end
|
|
end
|
|
|
|
describe "mark_terrain_queued!/1" do
|
|
test "sets terrain_queued to true for given IDs" do
|
|
q1 = create_contact(%{station1: "A1A"})
|
|
q2 = create_contact(%{station1: "B2B"})
|
|
|
|
Radio.mark_terrain_queued!([q1.id, q2.id])
|
|
|
|
assert Repo.get!(Contact, q1.id).terrain_status == :queued
|
|
assert Repo.get!(Contact, q2.id).terrain_status == :queued
|
|
end
|
|
|
|
test "does not affect other QSOs" do
|
|
q1 = create_contact(%{station1: "A1A"})
|
|
q2 = create_contact(%{station1: "B2B"})
|
|
|
|
Radio.mark_terrain_queued!([q1.id])
|
|
|
|
assert Repo.get!(Contact, q1.id).terrain_status == :queued
|
|
assert Repo.get!(Contact, q2.id).terrain_status == :pending
|
|
end
|
|
|
|
test "handles empty list" do
|
|
assert Radio.mark_terrain_queued!([]) == {0, nil}
|
|
end
|
|
end
|
|
|
|
describe "contact_path_points/1" do
|
|
test "returns pos1, midpoint, and pos2 when both positions exist" do
|
|
contact =
|
|
create_contact(%{
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
|
})
|
|
|
|
points = Radio.contact_path_points(contact)
|
|
|
|
assert length(points) == 3
|
|
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points
|
|
assert lat1 == 32.9
|
|
assert lon1 == -97.0
|
|
assert_in_delta mid_lat, 31.6, 0.01
|
|
assert_in_delta mid_lon, -97.35, 0.01
|
|
assert lat2 == 30.3
|
|
assert lon2 == -97.7
|
|
end
|
|
|
|
test "returns only pos1 when pos2 is nil" do
|
|
contact = create_contact(%{pos2: nil})
|
|
|
|
points = Radio.contact_path_points(contact)
|
|
|
|
assert points == [{32.9, -97.0}]
|
|
end
|
|
|
|
test "returns empty list when pos1 is nil" do
|
|
contact = create_contact(%{pos1: nil})
|
|
|
|
assert Radio.contact_path_points(contact) == []
|
|
end
|
|
|
|
test "handles lon key in position maps" do
|
|
contact =
|
|
create_contact(%{
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
|
})
|
|
|
|
points = Radio.contact_path_points(contact)
|
|
assert length(points) == 3
|
|
end
|
|
|
|
test "computes a great-circle midpoint across the anti-meridian" do
|
|
# Two stations on opposite sides of the 180° line. The arithmetic
|
|
# mean of -179 and +179 lands at 0° (Greenwich) — clearly wrong;
|
|
# the geodesic midpoint sits near the date line at ±180°.
|
|
contact =
|
|
create_contact(%{
|
|
pos1: %{"lat" => 0.0, "lon" => 179.0},
|
|
pos2: %{"lat" => 0.0, "lon" => -179.0}
|
|
})
|
|
|
|
[_, {mid_lat, mid_lon}, _] = Radio.contact_path_points(contact)
|
|
|
|
assert_in_delta mid_lat, 0.0, 0.01
|
|
assert abs(mid_lon) > 179.0
|
|
end
|
|
end
|
|
|
|
describe "change_contact/2" do
|
|
test "returns a submission changeset" do
|
|
changeset = Radio.change_contact(%Contact{})
|
|
assert %Ecto.Changeset{} = changeset
|
|
end
|
|
|
|
test "accepts attrs" do
|
|
changeset = Radio.change_contact(%Contact{}, %{station1: "W5XD"})
|
|
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
|
|
end
|
|
end
|
|
|
|
describe "create_contact/1" do
|
|
@valid_submission %{
|
|
station1: "W5XD",
|
|
station2: "K5TR",
|
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
|
mode: "CW",
|
|
band: "1296",
|
|
grid1: "EM12",
|
|
grid2: "EM00",
|
|
submitter_email: "test@example.com"
|
|
}
|
|
|
|
test "creates QSO with computed positions and distance" do
|
|
assert {:ok, contact} = Radio.create_contact(@valid_submission)
|
|
|
|
assert contact.station1 == "W5XD"
|
|
assert contact.user_submitted == true
|
|
assert contact.pos1["lat"]
|
|
assert contact.pos1["lon"]
|
|
assert contact.pos2["lat"]
|
|
assert contact.pos2["lon"]
|
|
assert contact.distance_km
|
|
end
|
|
|
|
test "computes correct position from grid" do
|
|
assert {:ok, contact} = Radio.create_contact(@valid_submission)
|
|
|
|
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
|
|
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
|
|
assert_in_delta contact.pos1["lat"], 32.5, 0.1
|
|
assert_in_delta contact.pos1["lon"], -97.0, 0.1
|
|
end
|
|
|
|
test "sets user_submitted to true" do
|
|
assert {:ok, contact} = Radio.create_contact(@valid_submission)
|
|
assert contact.user_submitted == true
|
|
end
|
|
|
|
test "returns error changeset for invalid data" do
|
|
assert {:error, %Ecto.Changeset{}} = Radio.create_contact(%{})
|
|
end
|
|
|
|
test "returns error changeset for invalid grid" do
|
|
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
|
|
assert {:error, changeset} = Radio.create_contact(attrs)
|
|
assert errors_on(changeset).grid1
|
|
end
|
|
|
|
test "accepts authenticated submission without submitter_email" do
|
|
# Regression: validate_user_or_email must see user_id, not just attrs.
|
|
# Previously user_id was applied AFTER submission_changeset, so this
|
|
# call returned `{:error, %Ecto.Changeset{errors: [submitter_email: ...]}}`
|
|
# even though the user_id satisfies the ownership invariant.
|
|
user = user_fixture()
|
|
attrs = Map.delete(@valid_submission, :submitter_email)
|
|
|
|
assert {:ok, contact} = Radio.create_contact(attrs, user.id)
|
|
assert contact.user_id == user.id
|
|
assert is_nil(contact.submitter_email)
|
|
end
|
|
end
|
|
|
|
describe "list_contacts_for_user/1" do
|
|
test "returns only contacts whose user_id matches the user" do
|
|
user = user_fixture()
|
|
other = user_fixture()
|
|
|
|
owned =
|
|
create_contact(%{station1: "W5OWN", user_id: user.id})
|
|
|
|
_their =
|
|
create_contact(%{station1: "W5THR", user_id: other.id})
|
|
|
|
_anon = create_contact(%{station1: "W5ANO"})
|
|
|
|
assert [found] = Radio.list_contacts_for_user(user)
|
|
assert found.id == owned.id
|
|
end
|
|
|
|
test "orders newest first" do
|
|
user = user_fixture()
|
|
|
|
_old =
|
|
create_contact(%{
|
|
qso_timestamp: ~U[2020-01-01 00:00:00Z],
|
|
user_id: user.id,
|
|
station1: "W5OLD"
|
|
})
|
|
|
|
new =
|
|
create_contact(%{
|
|
qso_timestamp: ~U[2026-06-15 12:00:00Z],
|
|
user_id: user.id,
|
|
station1: "W5NEW"
|
|
})
|
|
|
|
assert [first, _] = Radio.list_contacts_for_user(user)
|
|
assert first.id == new.id
|
|
end
|
|
|
|
test "returns an empty list when the user has submitted nothing" do
|
|
user = user_fixture()
|
|
assert Radio.list_contacts_for_user(user) == []
|
|
end
|
|
|
|
test "includes private contacts when the viewer is the owner" do
|
|
user = user_fixture()
|
|
private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
|
|
|
|
assert [found] = Radio.list_contacts_for_user(user, user)
|
|
assert found.id == private.id
|
|
end
|
|
|
|
test "hides private contacts when the viewer is anonymous" do
|
|
user = user_fixture()
|
|
_private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
|
|
public = create_contact(%{station1: "W5PUB", user_id: user.id, private: false})
|
|
|
|
assert [found] = Radio.list_contacts_for_user(user)
|
|
assert found.id == public.id
|
|
end
|
|
|
|
test "hides private contacts from other authenticated viewers" do
|
|
user = user_fixture()
|
|
other = user_fixture()
|
|
_private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
|
|
|
|
assert Radio.list_contacts_for_user(user, other) == []
|
|
end
|
|
|
|
test "includes private contacts when the viewer is an admin" do
|
|
user = user_fixture()
|
|
admin = user_fixture()
|
|
admin = %{admin | is_admin: true}
|
|
private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
|
|
|
|
assert [found] = Radio.list_contacts_for_user(user, admin)
|
|
assert found.id == private.id
|
|
end
|
|
end
|
|
|
|
describe "contact_map_payload/0 visibility" do
|
|
test "excludes private contacts from the cached map payload" do
|
|
_private = create_contact(%{station1: "W5PRV", private: true})
|
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
|
|
|
%{json: json, count: count} = Radio.contact_map_payload()
|
|
|
|
assert count == 1
|
|
decoded = json |> IO.iodata_to_binary() |> Jason.decode!()
|
|
assert [[_, _, _, _, _, "W5PUB" | _]] = decoded
|
|
refute to_string(IO.iodata_to_binary(json)) =~ "W5PRV"
|
|
_ = public
|
|
end
|
|
end
|
|
|
|
describe "can_view?/2" do
|
|
test "public contact is viewable by anyone" do
|
|
contact = create_contact(%{private: false})
|
|
assert Radio.can_view?(contact, nil) == true
|
|
assert Radio.can_view?(contact, %Scope{user: user_fixture()}) == true
|
|
end
|
|
|
|
test "private contact hidden from anonymous and non-owner" do
|
|
owner = user_fixture()
|
|
contact = create_contact(%{private: true, user_id: owner.id})
|
|
assert Radio.can_view?(contact, nil) == false
|
|
|
|
other = user_fixture()
|
|
assert Radio.can_view?(contact, %Scope{user: other}) == false
|
|
end
|
|
|
|
test "private contact visible to owner and admin" do
|
|
owner = user_fixture()
|
|
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
|
contact = create_contact(%{private: true, user_id: owner.id})
|
|
|
|
assert Radio.can_view?(contact, %Scope{user: owner}) == true
|
|
assert Radio.can_view?(contact, %Scope{user: admin}) == true
|
|
end
|
|
end
|
|
|
|
describe "list_contacts/2 scope-aware visibility" do
|
|
test "anonymous viewer sees only non-private" do
|
|
_private = create_contact(%{station1: "W5PRV", private: true})
|
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
|
|
|
%{entries: entries} = Radio.list_contacts(scope: nil)
|
|
assert Enum.map(entries, & &1.id) == [public.id]
|
|
end
|
|
|
|
test "owner sees their own private contacts" do
|
|
owner = user_fixture()
|
|
scope = %Scope{user: owner}
|
|
private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
|
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
|
|
|
%{entries: entries} = Radio.list_contacts(scope: scope)
|
|
ids = entries |> Enum.map(& &1.id) |> Enum.sort()
|
|
assert Enum.sort([private.id, public.id]) == ids
|
|
end
|
|
|
|
test "admin sees all private contacts" do
|
|
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
|
scope = %Scope{user: admin}
|
|
_other_user = user_fixture()
|
|
private = create_contact(%{station1: "W5PRV", private: true})
|
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
|
|
|
%{entries: entries} = Radio.list_contacts(scope: scope)
|
|
ids = entries |> Enum.map(& &1.id) |> Enum.sort()
|
|
assert Enum.sort([private.id, public.id]) == ids
|
|
end
|
|
|
|
test "non-owning non-admin cannot see other users' private" do
|
|
someone_else = user_fixture()
|
|
viewer = user_fixture()
|
|
scope = %Scope{user: viewer}
|
|
_private = create_contact(%{station1: "W5PRV", private: true, user_id: someone_else.id})
|
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
|
|
|
%{entries: entries} = Radio.list_contacts(scope: scope)
|
|
assert Enum.map(entries, & &1.id) == [public.id]
|
|
end
|
|
end
|
|
|
|
describe "list_contacts_involving_callsign/1" do
|
|
test "returns contacts where the callsign is station1" do
|
|
match = create_contact(%{station1: "W5ISP", station2: "K5OTR"})
|
|
_other = create_contact(%{station1: "W5OTH", station2: "K5OTR"})
|
|
|
|
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
|
|
assert found.id == match.id
|
|
end
|
|
|
|
test "returns contacts where the callsign is station2" do
|
|
match = create_contact(%{station1: "W5OTH", station2: "W5ISP"})
|
|
_other = create_contact(%{station1: "W5OTH", station2: "K5OTR"})
|
|
|
|
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
|
|
assert found.id == match.id
|
|
end
|
|
|
|
test "matching is case-insensitive" do
|
|
match = create_contact(%{station1: "W5ISP", station2: "K5OTR"})
|
|
|
|
assert [found] = Radio.list_contacts_involving_callsign("w5isp")
|
|
assert found.id == match.id
|
|
end
|
|
|
|
test "orders newest first" do
|
|
_old =
|
|
create_contact(%{
|
|
station1: "W5ISP",
|
|
station2: "K5A",
|
|
qso_timestamp: ~U[2020-01-01 00:00:00Z]
|
|
})
|
|
|
|
new =
|
|
create_contact(%{
|
|
station1: "K5B",
|
|
station2: "W5ISP",
|
|
qso_timestamp: ~U[2026-06-15 12:00:00Z]
|
|
})
|
|
|
|
assert [first, _] = Radio.list_contacts_involving_callsign("W5ISP")
|
|
assert first.id == new.id
|
|
end
|
|
|
|
test "returns empty list when no contacts match" do
|
|
create_contact(%{station1: "W5OTH", station2: "K5OTR"})
|
|
|
|
assert Radio.list_contacts_involving_callsign("W5ISP") == []
|
|
end
|
|
|
|
test "returns empty list for blank or nil callsign" do
|
|
assert Radio.list_contacts_involving_callsign("") == []
|
|
assert Radio.list_contacts_involving_callsign(nil) == []
|
|
end
|
|
|
|
test "excludes private contacts" do
|
|
_private = create_contact(%{station1: "W5ISP", station2: "K5OTR", private: true})
|
|
visible = create_contact(%{station1: "W5ISP", station2: "K5OTR", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
|
|
|
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
|
|
assert found.id == visible.id
|
|
end
|
|
|
|
test "caps the result at the 100 most recent contacts" do
|
|
base = ~U[2020-01-01 00:00:00Z]
|
|
|
|
for i <- 1..120 do
|
|
create_contact(%{
|
|
station1: "W5ISP",
|
|
station2: "K5X#{i}",
|
|
qso_timestamp: DateTime.add(base, i * 3600, :second)
|
|
})
|
|
end
|
|
|
|
result = Radio.list_contacts_involving_callsign("W5ISP")
|
|
|
|
assert length(result) == 100
|
|
# The 100 newest by qso_timestamp correspond to i = 21..120; i=120 is newest.
|
|
assert hd(result).station2 == "K5X120"
|
|
end
|
|
end
|
|
end
|