defmodule Microwaveprop.Radio.ContactSubmissionTest do use Microwaveprop.DataCase, async: true use ExUnitProperties alias Microwaveprop.Radio.Contact @valid_attrs %{ 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" } describe "submission_changeset/2" do test "valid attrs produce a valid changeset" do changeset = Contact.submission_changeset(%Contact{}, @valid_attrs) assert changeset.valid? end test "requires station1, station2, qso_timestamp, band, grid1, grid2, submitter_email" do changeset = Contact.submission_changeset(%Contact{}, %{}) errors = errors_on(changeset) assert errors[:station1] assert errors[:station2] assert errors[:qso_timestamp] assert errors[:band] assert errors[:grid1] assert errors[:grid2] assert errors[:submitter_email] # Mode is optional on submission refute errors[:mode] end test "accepts a blank mode and normalises it to nil" do for blank <- ["", " ", nil] do attrs = Map.put(@valid_attrs, :mode, blank) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "Expected blank mode #{inspect(blank)} to validate" assert Ecto.Changeset.get_field(changeset, :mode) == nil end end test "rejects invalid grid1" do attrs = Map.put(@valid_attrs, :grid1, "ZZ99") changeset = Contact.submission_changeset(%Contact{}, attrs) assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1 end test "rejects invalid grid2" do attrs = Map.put(@valid_attrs, :grid2, "ZZ99") changeset = Contact.submission_changeset(%Contact{}, attrs) assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2 end test "accepts valid 6-char grids" do attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"} changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid? end test "rejects invalid mode" do attrs = Map.put(@valid_attrs, :mode, "RTTY") changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset).mode end test "accepts valid modes" do for mode <- ~w(CW SSB FM FT8 FT4 Q65) do attrs = Map.put(@valid_attrs, :mode, mode) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "Expected mode #{mode} to be valid" end end test "rejects invalid band" do attrs = Map.put(@valid_attrs, :band, "28") changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset).band end test "accepts valid bands" do for band <- ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 75000) do attrs = Map.put(@valid_attrs, :band, band) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "Expected band #{band} to be valid" end end test "rejects email without @" do attrs = Map.put(@valid_attrs, :submitter_email, "notanemail") changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset).submitter_email end test "accepts private flag and defaults to false" do changeset = Contact.submission_changeset(%Contact{}, @valid_attrs) assert changeset.valid? assert Ecto.Changeset.get_field(changeset, :private) == false attrs = Map.put(@valid_attrs, :private, true) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid? assert Ecto.Changeset.get_field(changeset, :private) == true end test "does not cast user_submitted" do attrs = Map.put(@valid_attrs, :user_submitted, true) changeset = Contact.submission_changeset(%Contact{}, attrs) refute Ecto.Changeset.get_change(changeset, :user_submitted) end test "accepts antenna heights within [0, 1000] ft" do for {h1, h2} <- [{0, 0}, {10, 10}, {500, 750}, {999, 1000}] do attrs = Map.merge(@valid_attrs, %{height1_ft: h1, height2_ft: h2}) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "expected heights h1=#{h1} h2=#{h2} to validate" end end test "rejects negative antenna heights" do attrs = Map.put(@valid_attrs, :height1_ft, -5) changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset)[:height1_ft] end test "rejects antenna heights above 1000 ft" do attrs = Map.put(@valid_attrs, :height2_ft, 1001) changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset)[:height2_ft] end test "collapses a whitespace-only mode to no-mode (not an invalid-mode error)" do for blank <- ["", " ", "\t", "\n "] do attrs = Map.put(@valid_attrs, :mode, blank) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "expected blank mode #{inspect(blank)} to validate" refute errors_on(changeset)[:mode] end end property "accepts every sanctioned band" do check all( band <- StreamData.member_of( ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000) ) ) do attrs = Map.put(@valid_attrs, :band, band) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid? end end property "rejects any band string outside the sanctioned list" do check all( band <- string(:alphanumeric, min_length: 1, max_length: 10), band not in ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000) ) do attrs = Map.put(@valid_attrs, :band, band) changeset = Contact.submission_changeset(%Contact{}, attrs) refute changeset.valid? assert errors_on(changeset)[:band] end end end describe "notes field" do test "accepts a notes string" do attrs = Map.put(@valid_attrs, :notes, "Rare opening via sporadic-E along the TX/OK front") changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :notes) =~ "sporadic-E" end test "trims whitespace and treats blank notes as nil" do for blank <- ["", " ", "\n\t "] do attrs = Map.put(@valid_attrs, :notes, blank) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "expected blank notes #{inspect(blank)} to validate" refute Ecto.Changeset.get_change(changeset, :notes) in [blank] end end test "rejects notes longer than 2000 characters" do attrs = Map.put(@valid_attrs, :notes, String.duplicate("x", 2001)) changeset = Contact.submission_changeset(%Contact{}, attrs) refute changeset.valid? assert errors_on(changeset)[:notes] end property "accepts any non-blank string up to 2000 graphemes" do check all( len <- integer(1..2000), notes <- string(:printable, length: len), String.trim(notes) != "" ) do attrs = Map.put(@valid_attrs, :notes, notes) changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "expected #{len}-char notes to validate, got errors: " <> inspect(errors_on(changeset)) end end property "rejects any notes string longer than 2000 characters" do check all( overage <- integer(1..500), notes <- string(:alphanumeric, length: 2000 + overage) ) do attrs = Map.put(@valid_attrs, :notes, notes) changeset = Contact.submission_changeset(%Contact{}, attrs) refute changeset.valid?, "expected #{String.length(notes)}-char notes to be rejected" assert errors_on(changeset)[:notes] end end end describe "create_contact/1 deduplication" do alias Microwaveprop.Radio test "rejects duplicate and returns the existing contact" do assert {:ok, original} = Radio.create_contact(@valid_attrs) assert {:error, :duplicate, ^original} = Radio.create_contact(@valid_attrs) end test "allows same stations on a different band" do assert {:ok, _} = Radio.create_contact(@valid_attrs) assert {:ok, _} = Radio.create_contact(%{@valid_attrs | band: "2304"}) end test "allows same stations with different grids" do assert {:ok, _} = Radio.create_contact(@valid_attrs) assert {:ok, _} = Radio.create_contact(%{@valid_attrs | grid1: "EM13"}) end test "allows contact outside the 1-hour window" do assert {:ok, _} = Radio.create_contact(@valid_attrs) later = DateTime.add(@valid_attrs.qso_timestamp, 3601, :second) assert {:ok, _} = Radio.create_contact(%{@valid_attrs | qso_timestamp: later}) end test "detects duplicates regardless of station order" do assert {:ok, original} = Radio.create_contact(@valid_attrs) swapped = %{@valid_attrs | station1: "K5TR", grid1: "EM00", station2: "W5XD", grid2: "EM12"} assert {:error, :duplicate, ^original} = Radio.create_contact(swapped) end end end