defmodule Microwaveprop.Radio.CsvImportTest do # async: false — CsvImport.commit/1 inserts contacts that trigger inline # Oban enrichment jobs (IEMRE, weather, HRRR, terrain) which upsert rows # with a shared conflict target. Running concurrently with sibling # DataCase tests produced intermittent "ShareLock deadlock_detected". use Microwaveprop.DataCase, async: false use Oban.Testing, repo: Microwaveprop.Repo 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 "strips non-printables (BOM, NBSP, zero-width) from uploaded CSV" do # \uFEFF BOM at file start, NBSP between fields, zero-width space inside callsign. csv = "\uFEFF" <> @valid_header <> "\nW5XD,K5\u200BTR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z\u00A0" assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.station1 == "W5XD" assert contact.station2 == "K5TR" 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 test "accepts an 8-column CSV with a notes column" do csv = "station1,station2,grid1,grid2,band,mode,qso_timestamp,notes\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z,Strong Es opening" assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.notes == "Strong Es opening" end test "accepts a notes column with embedded commas and quotes" do # RFC 4180 quoted field: "He said ""hi"" and signed, 73" csv = "station1,station2,grid1,grid2,band,mode,qso_timestamp,notes\n" <> ~s(W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z,"He said ""hi"" and signed, 73") assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.notes == ~s(He said "hi" and signed, 73) end test "blank notes cell stores NULL, not empty string" do csv = "station1,station2,grid1,grid2,band,mode,qso_timestamp,notes\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z," assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.notes == 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 "import/2 with header-driven column mapping" do # The /contacts export produces a rich CSV with label-style headers and # extra columns (id, distance_km, status fields, inserted_at). The import # must read the header row, match by field name / label, and ignore # anything it doesn't recognize so export → import round-trips. test "accepts /contacts export format (labels + extra columns + different order)" do # Mirrors the live_table export: 12 columns, labels like "Station 1", # grids interleaved (station1, grid1, station2, grid2…), and extra # columns the importer should ignore. csv = """ ID,Station 1,Grid 1,Station 2,Grid 2,Band,Mode,Distance (km),Enriched,Flag,QSO (UTC),Added (UTC) abc-123,W5XD,EM12,K5TR,EM00,10000,CW,295,Complete,,2026-03-28 18:00,2026-03-28 18:05 """ assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.station1 == "W5XD" assert contact.station2 == "K5TR" assert contact.grid1 == "EM12" assert contact.grid2 == "EM00" assert contact.mode == "CW" end test "accepts arbitrary column order" do csv = """ qso_timestamp,band,mode,grid2,grid1,station2,station1 2026-03-28T18:00:00Z,10000,CW,EM00,EM12,K5TR,W5XD """ assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.station1 == "W5XD" assert contact.grid1 == "EM12" end test "is case-insensitive on header names" do csv = """ STATION1,STATION2,GRID1,GRID2,BAND,MODE,QSO_TIMESTAMP W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z """ assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.station1 == "W5XD" end test "ignores unknown columns" do csv = """ station1,notes,station2,grid1,ignored_col,grid2,band,mode,qso_timestamp W5XD,some note,K5TR,EM12,xyz,EM00,10000,CW,2026-03-28T18:00:00Z """ assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.station1 == "W5XD" end test "rejects when a required column header is missing" do # No qso_timestamp column at all. csv = """ station1,station2,grid1,grid2,band,mode W5XD,K5TR,EM12,EM00,10000,CW """ assert {:error, {:missing_required_columns, missing}} = CsvImport.import(csv, @submitter) assert "qso_timestamp" in missing 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.refinements == [] 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 second.refinements == [] 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 assert preview.refinements == [] end test "upload with longer grid than existing routes to refinements" do csv = @valid_header <> "\n" <> @valid_row {:ok, preview} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [existing]}} = CsvImport.commit(preview.valid) refined_csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z" assert {:ok, p2} = CsvImport.preview(refined_csv, @submitter) assert p2.valid == [] assert p2.duplicates == [] assert [ref] = p2.refinements assert ref.row_num == 2 assert ref.existing_id == existing.id assert ref.changes == %{grid1: "EM12KP37"} end test "direction-swapped refinement maps changes to existing orientation" do csv = @valid_header <> "\n" <> @valid_row {:ok, preview} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [existing]}} = CsvImport.commit(preview.valid) # Upload as K5TR's side with more-precise grids swapped_csv = @valid_header <> "\n" <> "K5TR,W5XD,EM00bb,EM12aa,10000,CW,2026-03-28T18:05:00Z" assert {:ok, p2} = CsvImport.preview(swapped_csv, @submitter) assert p2.valid == [] assert [ref] = p2.refinements assert ref.existing_id == existing.id assert ref.changes == %{grid1: "EM12AA", grid2: "EM00BB"} end test "upload with mode filling in missing mode routes to refinements" do {:ok, existing} = Microwaveprop.Radio.create_contact(%{ station1: "W5XD", station2: "K5TR", grid1: "EM12", grid2: "EM00", band: "10000", qso_timestamp: ~U[2026-03-28 18:00:00Z], submitter_email: @submitter }) refined_csv = @valid_header <> "\n" <> @valid_row assert {:ok, p2} = CsvImport.preview(refined_csv, @submitter) assert p2.valid == [] assert [ref] = p2.refinements assert ref.existing_id == existing.id assert ref.changes == %{mode: "CW"} end test "contradictory grid (same 4-char prefix, different 6th char) is a duplicate not refinement" do csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12kp,EM00,10000,CW,2026-03-28T18:00:00Z" {:ok, preview} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [_existing]}} = CsvImport.commit(preview.valid) contradictory_csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12qq,EM00,10000,CW,2026-03-28T18:05:00Z" assert {:ok, p2} = CsvImport.preview(contradictory_csv, @submitter) assert p2.valid == [] assert p2.refinements == [] assert [%{reason: :existing_contact}] = p2.duplicates end test "contradictory mode is a duplicate not refinement" do csv = @valid_header <> "\n" <> @valid_row {:ok, preview} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [_]}} = CsvImport.commit(preview.valid) mismatched_csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,SSB,2026-03-28T18:05:00Z" assert {:ok, p2} = CsvImport.preview(mismatched_csv, @submitter) assert p2.valid == [] assert p2.refinements == [] assert [%{reason: :existing_contact}] = p2.duplicates end test "rover with different 4-char prefix still classifies as valid" do csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z" {:ok, preview} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [_]}} = CsvImport.commit(preview.valid) rover_csv = @valid_header <> "\n" <> "W5XD,K5TR,EM13,EM00,10000,CW,2026-03-28T18:15:00Z" assert {:ok, p2} = CsvImport.preview(rover_csv, @submitter) assert length(p2.valid) == 1 assert p2.refinements == [] assert p2.duplicates == [] 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 test "commit accepts map with :valid and :refinements and applies both" do csv = @valid_header <> "\n" <> @valid_row {:ok, p1} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [existing]}} = CsvImport.commit(p1.valid) refined_csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z" {:ok, p2} = CsvImport.preview(refined_csv, @submitter) assert [_ref] = p2.refinements assert {:ok, %{imported: [], refined: [refined], errors: []}} = CsvImport.commit(%{valid: p2.valid, refinements: p2.refinements}) assert refined.id == existing.id assert refined.grid1 == "EM12KP37" end test "commit with grid refinement re-enqueues enrichment" do csv = @valid_header <> "\n" <> @valid_row {:ok, p1} = CsvImport.preview(csv, @submitter) {:ok, %{imported: [existing]}} = CsvImport.commit(p1.valid) # Drive enrichment statuses to :complete so a reset is observable. Repo.update!( Ecto.Changeset.change(existing, %{ hrrr_status: :complete, weather_status: :complete, terrain_status: :complete, iemre_status: :complete }) ) refined_csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z" {:ok, p2} = CsvImport.preview(refined_csv, @submitter) assert {:ok, %{refined: [refined]}} = CsvImport.commit(%{valid: p2.valid, refinements: p2.refinements}) # After apply_contact_refinement + re-enqueue, HRRR enrichment ran # again and (because the HRRR stub returns 404) lands at :unavailable. # Weather/iemre/terrain may resolve back to :complete if no jobs could # be built, so HRRR is the definitive "was it re-enqueued" signal. reloaded = Repo.reload!(refined) refute reloaded.hrrr_status == :complete end test "commit with mode-only refinement does not re-enqueue enrichment" do {:ok, existing} = Microwaveprop.Radio.create_contact(%{ station1: "W5XD", station2: "K5TR", grid1: "EM12", grid2: "EM00", band: "10000", qso_timestamp: ~U[2026-03-28 18:00:00Z], submitter_email: @submitter }) Repo.update!( Ecto.Changeset.change(existing, %{ hrrr_status: :complete, weather_status: :complete, terrain_status: :complete, iemre_status: :complete }) ) refined_csv = @valid_header <> "\n" <> @valid_row {:ok, p} = CsvImport.preview(refined_csv, @submitter) assert [%{changes: %{mode: "CW"}}] = p.refinements assert {:ok, %{refined: [refined]}} = CsvImport.commit(%{valid: p.valid, refinements: p.refinements}) reloaded = Repo.reload!(refined) assert reloaded.mode == "CW" assert reloaded.hrrr_status == :complete assert reloaded.weather_status == :complete assert reloaded.terrain_status == :complete assert reloaded.iemre_status == :complete end test "commit(list) backward compat still works" do csv = Enum.join( [ @valid_header, "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z" ], "\n" ) {:ok, preview} = CsvImport.preview(csv, @submitter) assert {:ok, result} = CsvImport.commit(preview.valid) assert [_contact] = result.imported assert result.errors == [] # The legacy list form still ships a :refined key with an empty list. assert result.refined == [] end end describe "enqueue/2" do alias Microwaveprop.Radio.ImportRun alias Microwaveprop.Workers.ContactImportWorker # Uses testing: :inline — jobs run as they're inserted. We inspect # the post-run state (which exercises the worker too) and then # reset-and-re-count to verify the pre-run shape separately. test "creates an ImportRun with all rows serialized" do valid = [ %{ row_num: 2, attrs: %{ "station1" => "W5XD", "station2" => "K5TR", "grid1" => "EM12", "grid2" => "EM00", "band" => "10000", "mode" => "CW", "qso_timestamp" => "2026-03-28T18:00:00Z", "submitter_email" => @submitter }, timestamp: ~U[2026-03-28 18:00:00Z] } ] preview = %{valid: valid, refinements: []} assert {:ok, run_id} = CsvImport.enqueue(preview, @submitter) run = Repo.get!(ImportRun, run_id) assert run.submitter_email == @submitter assert run.total_rows == 1 assert [%{"kind" => "insert", "row_num" => 2}] = run.rows["rows"] assert run.rows["refinement_ids"] == [] end test "enqueues one ContactImportWorker job per chunk" do # 250 rows -> 3 jobs at chunk_size 100. valid = for i <- 0..249 do %{ row_num: i + 2, attrs: %{ "station1" => "W5XD", "station2" => "K5TR", "grid1" => "EM12", "grid2" => "EM00", "band" => "10000", "mode" => "CW", "qso_timestamp" => "2026-03-28T18:00:00Z", "submitter_email" => @submitter }, timestamp: ~U[2026-03-28 18:00:00Z] } end preview = %{valid: valid, refinements: []} {:ok, _run_id} = Oban.Testing.with_testing_mode(:manual, fn -> CsvImport.enqueue(preview, @submitter) end) # Three chunks: [0, 100), [100, 200), [200, 250). assert_enqueued(worker: ContactImportWorker, args: %{"offset" => 0, "limit" => 100}) assert_enqueued(worker: ContactImportWorker, args: %{"offset" => 100, "limit" => 100}) assert_enqueued(worker: ContactImportWorker, args: %{"offset" => 200, "limit" => 50}) assert length(all_enqueued(worker: ContactImportWorker)) == 3 end test "returns pre-run counters and status on the persisted run" do # Fresh empty run so counters show their defaults regardless of # what the inline worker does. preview = %{valid: [], refinements: []} assert {:ok, run_id} = CsvImport.enqueue(preview, @submitter) run = Repo.get!(ImportRun, run_id) assert run.total_rows == 0 assert run.processed_rows == 0 assert run.imported_count == 0 assert run.refined_count == 0 assert run.error_count == 0 assert run.errors == %{} # No rows, no jobs enqueued — status stays pending. assert run.status == "pending" end end end