prop/test/microwaveprop/radio/csv_import_test.exs
Graham McIntire ec388e4bf5
Use Timex for flexible CSV timestamp parsing with tests
- Add timex ~> 3.7, downgrade gettext to ~> 0.26 for compatibility
- Parse ISO 8601, US dates (M/D/YYYY), AM/PM, 24h, compact formats
- 22 tests covering all accepted timestamp formats and edge cases
- Integration tests for CSV import with US date and space-separated formats
2026-04-01 15:39:02 -05:00

301 lines
9.9 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
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,99999,CW,2026-03-28T18:00:00Z"
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
assert length(errors) > 0
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 length(errors) > 0
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 length(errors) > 0
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 length(errors) > 0
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 length(errors) > 0
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,99999,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
end
end