Profile page:
* New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
page showing a user's contacts and beacons. Resolves case-
insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
callsigns redirect to /. Uses daisyUI card / stats / table
components with an avatar-placeholder initial and hero icons.
* Accounts.get_user_by_callsign/1 (case-insensitive) plus
Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
back the page. Beacons list includes both approved and pending so
owners see their drafts.
* The top nav bar and the three LiveView sidebars (MapLive,
WeatherMapLive, ContactMapLive) now render the logged-in callsign
as a navigate link to /u/:callsign instead of a static label.
* Nine new tests cover the lookup, the LiveView render, and the
ownership-scoped queries.
Flexible band input:
* New Microwaveprop.Radio.BandResolver module converts any of:
ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
insensitive), numeric frequency strings ("903.100", "10368.000"),
and canonical MHz integers into the one of the site's known bands.
Returns the nearest allowed band for numeric inputs >= 900 MHz,
nil otherwise.
* 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
AdifImport.@allowed_bands, and the BandResolver list so "33cm"
round-trips end-to-end.
* AdifImport and CsvImport now delegate band resolution to
BandResolver, and Radio.create_contact/2 normalizes the :band attr
on the way in so the manual form and any API callers benefit too.
CsvImport's "invalid band" tests previously used 99999 MHz which
the new resolver snaps to the nearest allowed band; swapped to
"notaband" which is truly unresolvable.
Contacts and beacons list UX:
* Remove the "Submitted" column from /contacts — it duplicated info
already visible on the detail page and was pushing the real
columns off narrow viewports. submitted_cell/1 and its three
column-specific tests go with it.
* Hide the Lat / Lon columns from /beacons — six decimal places of
coordinates weren't useful next to the grid square and took a
disproportionate amount of row width.
592 lines
20 KiB
Elixir
592 lines
20 KiB
Elixir
defmodule Microwaveprop.Radio.CsvImportTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Radio.CsvImport
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Weather.HrrrClient
|
|
alias Microwaveprop.Weather.IemClient
|
|
|
|
setup do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
case conn.request_path do
|
|
"/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
|
|
_ -> Req.Test.json(conn, %{"profiles" => []})
|
|
end
|
|
end)
|
|
|
|
Req.Test.stub(HrrrClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
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)
|
|
|
|
:ok
|
|
end
|
|
|
|
@valid_header "station1,station2,grid1,grid2,band,mode,qso_timestamp"
|
|
@valid_row "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
|
|
@submitter "test@example.com"
|
|
|
|
describe "import/2 with empty or missing data" do
|
|
test "returns error for empty string" do
|
|
assert {:error, :empty_csv} = CsvImport.import("", @submitter)
|
|
end
|
|
|
|
test "returns error for whitespace-only string" do
|
|
assert {:error, :empty_csv} = CsvImport.import(" \n \n ", @submitter)
|
|
end
|
|
|
|
test "returns error for header-only CSV" do
|
|
assert {:error, :no_data_rows} = CsvImport.import(@valid_header, @submitter)
|
|
end
|
|
|
|
test "returns error for header plus blank lines only" do
|
|
csv = @valid_header <> "\n\n\n"
|
|
assert {:error, :no_data_rows} = CsvImport.import(csv, @submitter)
|
|
end
|
|
end
|
|
|
|
describe "import/2 with valid data" do
|
|
test "imports a single valid row" do
|
|
csv = @valid_header <> "\n" <> @valid_row
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert %Contact{} = contact
|
|
assert contact.station1 == "W5XD"
|
|
assert contact.station2 == "K5TR"
|
|
assert contact.grid1 == "EM12"
|
|
assert contact.grid2 == "EM00"
|
|
assert contact.mode == "CW"
|
|
assert contact.user_submitted == true
|
|
assert contact.submitter_email == @submitter
|
|
assert contact.pos1
|
|
assert contact.pos2
|
|
assert contact.distance_km
|
|
end
|
|
|
|
test "imports multiple valid rows" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, %{imported: imported, errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert length(imported) == 2
|
|
end
|
|
|
|
test "trims whitespace from fields" do
|
|
csv = @valid_header <> "\n" <> " W5XD , K5TR , EM12 , EM00 , 10000 , CW , 2026-03-28T18:00:00Z "
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert contact.station1 == "W5XD"
|
|
assert contact.station2 == "K5TR"
|
|
end
|
|
|
|
test "handles Windows line endings" do
|
|
csv = @valid_header <> "\r\n" <> @valid_row <> "\r\n"
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert contact.station1 == "W5XD"
|
|
end
|
|
|
|
test "skips blank lines between data rows" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
@valid_row,
|
|
"",
|
|
"",
|
|
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, %{imported: imported, errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert length(imported) == 2
|
|
end
|
|
|
|
test "contacts are persisted to the database" do
|
|
csv = @valid_header <> "\n" <> @valid_row
|
|
|
|
assert {:ok, _result} = CsvImport.import(csv, @submitter)
|
|
assert Repo.aggregate(Contact, :count) == 1
|
|
end
|
|
|
|
test "accepts a 6-column CSV without the mode column" do
|
|
csv =
|
|
"station1,station2,grid1,grid2,band,qso_timestamp\n" <>
|
|
"W5XD,K5TR,EM12,EM00,10000,2026-03-28T18:00:00Z"
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert contact.station1 == "W5XD"
|
|
assert contact.mode == nil
|
|
end
|
|
|
|
test "accepts a 7-column CSV with a blank mode value" do
|
|
csv =
|
|
"station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
|
|
"W5XD,K5TR,EM12,EM00,10000,,2026-03-28T18:00:00Z"
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert contact.mode == nil
|
|
end
|
|
end
|
|
|
|
describe "import/2 with invalid data" do
|
|
test "reports wrong column count with row number" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12"
|
|
|
|
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
|
assert Enum.any?(errors, &String.contains?(&1, "column"))
|
|
end
|
|
|
|
test "reports invalid band" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
|
|
|
|
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
|
assert errors != []
|
|
end
|
|
|
|
test "reports invalid grid square" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,ZZZZ,EM00,10000,CW,2026-03-28T18:00:00Z"
|
|
|
|
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
|
assert errors != []
|
|
end
|
|
|
|
test "reports invalid mode" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,RTTY,2026-03-28T18:00:00Z"
|
|
|
|
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
|
assert errors != []
|
|
end
|
|
|
|
test "reports missing required fields" do
|
|
csv = @valid_header <> "\n" <> ",K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
|
|
|
|
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
|
assert errors != []
|
|
end
|
|
|
|
test "reports invalid timestamp" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,not-a-date"
|
|
|
|
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
|
assert errors != []
|
|
end
|
|
end
|
|
|
|
describe "import/2 with mixed valid and invalid rows" do
|
|
test "imports valid rows and reports invalid rows" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
@valid_row,
|
|
"W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z",
|
|
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, %{imported: imported, errors: errors}} = CsvImport.import(csv, @submitter)
|
|
assert length(imported) == 2
|
|
assert [{3, _error_msgs}] = errors
|
|
end
|
|
|
|
test "imports row with US date format" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,6/15/2024 2:30 PM"
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert contact.qso_timestamp == ~U[2024-06-15 14:30:00Z]
|
|
end
|
|
|
|
test "imports row with space-separated datetime" do
|
|
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2024-06-15 14:30"
|
|
|
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
|
assert contact.qso_timestamp == ~U[2024-06-15 14:30:00Z]
|
|
end
|
|
|
|
test "row numbers account for header and blank lines" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
@valid_row,
|
|
"",
|
|
"bad row with wrong columns",
|
|
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, %{imported: imported, errors: errors}} = CsvImport.import(csv, @submitter)
|
|
assert length(imported) == 2
|
|
# Row 4 is the bad row (header=1, data=2, blank=3, bad=4, good=5)
|
|
assert [{4, _error_msgs}] = errors
|
|
end
|
|
end
|
|
|
|
describe "normalize_timestamp/1" do
|
|
test "parses ISO 8601 with Z" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15T14:30:00Z")
|
|
end
|
|
|
|
test "parses ISO 8601 with offset" do
|
|
assert {:ok, _} = CsvImport.normalize_timestamp("2024-06-15T14:30:00+00:00")
|
|
end
|
|
|
|
test "parses ISO without timezone" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15T14:30:00")
|
|
end
|
|
|
|
test "parses space-separated datetime" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15 14:30:00")
|
|
end
|
|
|
|
test "parses space-separated without seconds" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15 14:30")
|
|
end
|
|
|
|
test "parses date only" do
|
|
assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("2024-06-15")
|
|
end
|
|
|
|
test "parses US date with AM/PM" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("6/15/2024 2:30 PM")
|
|
end
|
|
|
|
test "parses US date with AM" do
|
|
assert {:ok, "2024-06-15T09:30:00Z"} = CsvImport.normalize_timestamp("6/15/2024 9:30 AM")
|
|
end
|
|
|
|
test "parses 12:00 AM as midnight" do
|
|
assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024 12:00 AM")
|
|
end
|
|
|
|
test "parses 12:00 PM as noon" do
|
|
assert {:ok, "2024-06-15T12:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024 12:00 PM")
|
|
end
|
|
|
|
test "parses zero-padded US date with AM/PM" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06/15/2024 02:30 PM")
|
|
end
|
|
|
|
test "parses US date 24h" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06/15/2024 14:30")
|
|
end
|
|
|
|
test "parses US date 24h unpadded" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("6/15/2024 14:30")
|
|
end
|
|
|
|
test "parses US date with dashes" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06-15-2024 14:30")
|
|
end
|
|
|
|
test "parses compact with T" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("20240615T143000")
|
|
end
|
|
|
|
test "parses compact with space" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("20240615 1430")
|
|
end
|
|
|
|
test "trims whitespace" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp(" 2024-06-15T14:30:00Z ")
|
|
end
|
|
|
|
test "returns error for garbage" do
|
|
assert {:error, "qso_timestamp could not be parsed: garbage"} =
|
|
CsvImport.normalize_timestamp("garbage")
|
|
end
|
|
|
|
test "returns error for empty string" do
|
|
assert {:error, _} = CsvImport.normalize_timestamp("")
|
|
end
|
|
|
|
test "parses US date with dots" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06.15.2024 14:30")
|
|
end
|
|
|
|
test "parses ISO with slashes" do
|
|
assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024/06/15 14:30")
|
|
end
|
|
|
|
test "parses US date only" do
|
|
assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024")
|
|
end
|
|
end
|
|
|
|
describe "normalize_timestamp/1 property tests" do
|
|
use ExUnitProperties
|
|
|
|
property "ISO 8601 format always parses successfully" do
|
|
check all(
|
|
year <- integer(2000..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
hour <- integer(0..23),
|
|
minute <- integer(0..59),
|
|
second <- integer(0..59)
|
|
) do
|
|
y = String.pad_leading(to_string(year), 4, "0")
|
|
m = String.pad_leading(to_string(month), 2, "0")
|
|
d = String.pad_leading(to_string(day), 2, "0")
|
|
h = String.pad_leading(to_string(hour), 2, "0")
|
|
mi = String.pad_leading(to_string(minute), 2, "0")
|
|
s = String.pad_leading(to_string(second), 2, "0")
|
|
|
|
iso = "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"
|
|
assert {:ok, ^iso} = CsvImport.normalize_timestamp(iso)
|
|
end
|
|
end
|
|
|
|
property "US date format with 24h time always parses" do
|
|
check all(
|
|
year <- integer(2000..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
hour <- integer(0..23),
|
|
minute <- integer(0..59),
|
|
sep <- member_of(["/", "-", "."])
|
|
) do
|
|
input = "#{month}#{sep}#{day}#{sep}#{year} #{hour}:#{String.pad_leading(to_string(minute), 2, "0")}"
|
|
assert {:ok, result} = CsvImport.normalize_timestamp(input)
|
|
assert String.ends_with?(result, "Z")
|
|
{:ok, dt, _} = DateTime.from_iso8601(result)
|
|
assert dt.year == year
|
|
assert dt.month == month
|
|
assert dt.day == day
|
|
end
|
|
end
|
|
|
|
property "US date with AM/PM parses correctly" do
|
|
check all(
|
|
year <- integer(2000..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
hour_12 <- integer(1..12),
|
|
minute <- integer(0..59),
|
|
ampm <- member_of(["AM", "PM", "am", "pm"])
|
|
) do
|
|
input = "#{month}/#{day}/#{year} #{hour_12}:#{String.pad_leading(to_string(minute), 2, "0")} #{ampm}"
|
|
assert {:ok, result} = CsvImport.normalize_timestamp(input)
|
|
{:ok, dt, _} = DateTime.from_iso8601(result)
|
|
|
|
expected_hour =
|
|
case {hour_12, String.upcase(ampm)} do
|
|
{12, "AM"} -> 0
|
|
{12, "PM"} -> 12
|
|
{h, "PM"} -> h + 12
|
|
{h, "AM"} -> h
|
|
end
|
|
|
|
assert dt.hour == expected_hour
|
|
assert dt.minute == minute
|
|
end
|
|
end
|
|
|
|
property "all parsed results are valid ISO 8601 UTC datetimes" do
|
|
formats =
|
|
StreamData.member_of([
|
|
fn y, m, d, h, mi -> "#{y}-#{m}-#{d}T#{h}:#{mi}:00Z" end,
|
|
fn y, m, d, h, mi -> "#{y}-#{m}-#{d} #{h}:#{mi}" end,
|
|
fn y, m, d, _h, _mi -> "#{y}-#{m}-#{d}" end,
|
|
fn y, m, d, h, mi -> "#{m}/#{d}/#{y} #{h}:#{mi}" end
|
|
])
|
|
|
|
check all(
|
|
year <- integer(2000..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
hour <- integer(0..23),
|
|
minute <- integer(0..59),
|
|
fmt <- formats
|
|
) do
|
|
y = String.pad_leading(to_string(year), 4, "0")
|
|
m = String.pad_leading(to_string(month), 2, "0")
|
|
d = String.pad_leading(to_string(day), 2, "0")
|
|
h = String.pad_leading(to_string(hour), 2, "0")
|
|
mi = String.pad_leading(to_string(minute), 2, "0")
|
|
|
|
input = fmt.(y, m, d, h, mi)
|
|
|
|
case CsvImport.normalize_timestamp(input) do
|
|
{:ok, result} ->
|
|
assert String.ends_with?(result, "Z")
|
|
assert {:ok, _, _} = DateTime.from_iso8601(result)
|
|
|
|
{:error, _} ->
|
|
:ok
|
|
end
|
|
end
|
|
end
|
|
|
|
property "garbage input never returns ok" do
|
|
check all(garbage <- string(:alphanumeric, min_length: 1, max_length: 5)) do
|
|
case CsvImport.normalize_timestamp(garbage) do
|
|
{:ok, _} -> flunk("Parsed garbage: #{inspect(garbage)}")
|
|
{:error, _} -> :ok
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "preview/2" do
|
|
test "returns empty/no-data errors like import/2" do
|
|
assert {:error, :empty_csv} = CsvImport.preview("", @submitter)
|
|
assert {:error, :no_data_rows} = CsvImport.preview(@valid_header, @submitter)
|
|
end
|
|
|
|
test "classifies a valid row as valid and nothing is inserted" do
|
|
csv = @valid_header <> "\n" <> @valid_row
|
|
|
|
assert {:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert [%{row_num: 2, attrs: attrs, timestamp: %DateTime{}}] = preview.valid
|
|
assert attrs["station1"] == "W5XD"
|
|
assert preview.invalid == []
|
|
assert preview.duplicates == []
|
|
assert preview.total_rows == 1
|
|
assert preview.submitter_email == @submitter
|
|
assert Repo.aggregate(Contact, :count) == 0
|
|
end
|
|
|
|
test "classifies bad rows as invalid" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,ZZZZ,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert preview.valid == []
|
|
assert length(preview.invalid) == 2
|
|
assert Enum.all?(preview.invalid, &(&1.messages != []))
|
|
end
|
|
|
|
test "flags an existing DB contact as a duplicate" do
|
|
csv = @valid_header <> "\n" <> @valid_row
|
|
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
{:ok, %{imported: [_]}} = CsvImport.commit(preview.valid)
|
|
|
|
assert {:ok, second} = CsvImport.preview(csv, @submitter)
|
|
assert second.valid == []
|
|
assert [%{row_num: 2, reason: :existing_contact}] = second.duplicates
|
|
end
|
|
|
|
test "treats direction swap as the same contact" do
|
|
csv1 = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
|
|
{:ok, p1} = CsvImport.preview(csv1, @submitter)
|
|
{:ok, _} = CsvImport.commit(p1.valid)
|
|
|
|
# Same contact from K5TR's side — stations and grids swapped.
|
|
csv2 = @valid_header <> "\n" <> "K5TR,W5XD,EM00,EM12,10000,CW,2026-03-28T18:20:00Z"
|
|
{:ok, p2} = CsvImport.preview(csv2, @submitter)
|
|
assert p2.valid == []
|
|
assert [%{reason: :existing_contact}] = p2.duplicates
|
|
end
|
|
|
|
test "flags an earlier row in the same upload as a duplicate" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:30:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert [%{row_num: 2}] = preview.valid
|
|
assert [%{row_num: 3, reason: :earlier_in_upload}] = preview.duplicates
|
|
end
|
|
|
|
test "rows more than an hour apart are not duplicates" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T19:30:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert length(preview.valid) == 2
|
|
assert preview.duplicates == []
|
|
end
|
|
|
|
test "different bands are never duplicates" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"W5XD,K5TR,EM12,EM00,24000,CW,2026-03-28T18:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert length(preview.valid) == 2
|
|
end
|
|
|
|
test "different grids are not duplicates (rover moved)" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"W5XD,K5TR,EM13,EM00,10000,CW,2026-03-28T18:15:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
assert {:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert length(preview.valid) == 2
|
|
end
|
|
end
|
|
|
|
describe "commit/1" do
|
|
test "inserts each valid row and returns the created contacts" do
|
|
csv =
|
|
Enum.join(
|
|
[
|
|
@valid_header,
|
|
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
|
|
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
|
|
],
|
|
"\n"
|
|
)
|
|
|
|
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
|
assert {:ok, %{imported: [a, b], errors: []}} = CsvImport.commit(preview.valid)
|
|
assert %Contact{station1: "W5XD"} = a
|
|
assert %Contact{station1: "N5AC"} = b
|
|
assert Repo.aggregate(Contact, :count) == 2
|
|
end
|
|
|
|
test "commit of [] is a no-op" do
|
|
assert {:ok, %{imported: [], errors: []}} = CsvImport.commit([])
|
|
end
|
|
end
|
|
end
|