defmodule Microwaveprop.Accounts.UserTest do use Microwaveprop.DataCase, async: true import Microwaveprop.AccountsFixtures alias Microwaveprop.Accounts.User @valid_attrs %{ callsign: "W5ABC", name: "Alice", email: "alice@example.com", password: "hunter2!!", password_confirmation: "hunter2!!" } describe "admin_email/0" do test "exposes the hard-coded admin email constant" do assert User.admin_email() == "graham@mcintire.me" end end describe "registration_changeset/3" do test "valid attrs produce a valid changeset" do changeset = User.registration_changeset(%User{}, @valid_attrs) assert changeset.valid? end test "requires callsign, name, email, and password" do changeset = User.registration_changeset(%User{}, %{}) errors = errors_on(changeset) assert errors[:callsign] assert errors[:name] assert errors[:email] assert errors[:password] end test "upcases callsign" do attrs = Map.put(@valid_attrs, :callsign, "w5abc") changeset = User.registration_changeset(%User{}, attrs) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :callsign) == "W5ABC" end test "rejects callsigns shorter than 3 chars" do attrs = Map.put(@valid_attrs, :callsign, "AB") changeset = User.registration_changeset(%User{}, attrs) assert "must be 3-10 letters and digits" in errors_on(changeset).callsign end test "rejects callsigns longer than 10 chars" do attrs = Map.put(@valid_attrs, :callsign, "ABCDEFGHIJK") changeset = User.registration_changeset(%User{}, attrs) assert "must be 3-10 letters and digits" in errors_on(changeset).callsign end test "rejects callsigns with non-alphanumeric chars" do attrs = Map.put(@valid_attrs, :callsign, "W5-AB") changeset = User.registration_changeset(%User{}, attrs) assert "must be 3-10 letters and digits" in errors_on(changeset).callsign end test "rejects name longer than 100 chars" do attrs = Map.put(@valid_attrs, :name, String.duplicate("a", 101)) changeset = User.registration_changeset(%User{}, attrs) assert errors_on(changeset).name end test "rejects email without @ sign" do attrs = Map.put(@valid_attrs, :email, "not-an-email") changeset = User.registration_changeset(%User{}, attrs) assert "must have the @ sign and no spaces" in errors_on(changeset).email end test "rejects email with whitespace" do attrs = Map.put(@valid_attrs, :email, "bad @example.com") changeset = User.registration_changeset(%User{}, attrs) assert "must have the @ sign and no spaces" in errors_on(changeset).email end test "rejects email longer than 160 chars" do long_email = String.duplicate("a", 161) <> "@example.com" attrs = Map.put(@valid_attrs, :email, long_email) changeset = User.registration_changeset(%User{}, attrs) assert errors_on(changeset).email end test "rejects password shorter than 8 chars" do attrs = %{@valid_attrs | password: "short", password_confirmation: "short"} changeset = User.registration_changeset(%User{}, attrs) assert errors_on(changeset).password end test "rejects password longer than 72 chars" do long_password = String.duplicate("a", 73) attrs = %{@valid_attrs | password: long_password, password_confirmation: long_password} changeset = User.registration_changeset(%User{}, attrs) assert errors_on(changeset).password end test "requires password confirmation to match" do attrs = %{@valid_attrs | password_confirmation: "different!"} changeset = User.registration_changeset(%User{}, attrs) assert "does not match password" in errors_on(changeset).password_confirmation end test "hashes password by default and clears virtual field" do changeset = User.registration_changeset(%User{}, @valid_attrs) assert changeset.valid? hashed = Ecto.Changeset.get_change(changeset, :hashed_password) assert is_binary(hashed) assert String.starts_with?(hashed, "$2b$") or String.starts_with?(hashed, "$2a$") refute Ecto.Changeset.get_change(changeset, :password) end test "does not hash password when hash_password: false" do changeset = User.registration_changeset(%User{}, @valid_attrs, hash_password: false) assert changeset.valid? refute Ecto.Changeset.get_change(changeset, :hashed_password) assert Ecto.Changeset.get_change(changeset, :password) == @valid_attrs.password end test "final persisted struct contains hashed_password and no plaintext" do {:ok, user} = %User{} |> User.registration_changeset(@valid_attrs) |> Repo.insert() assert is_binary(user.hashed_password) assert user.password == nil end test "enforces email uniqueness via unsafe_validate_unique" do {:ok, _first} = %User{} |> User.registration_changeset(@valid_attrs) |> Repo.insert() duplicate = User.registration_changeset(%User{}, %{@valid_attrs | callsign: "W5XYZ"}) refute duplicate.valid? assert "has already been taken" in errors_on(duplicate).email end test "enforces callsign uniqueness via unsafe_validate_unique" do {:ok, _first} = %User{} |> User.registration_changeset(@valid_attrs) |> Repo.insert() duplicate = User.registration_changeset(%User{}, %{@valid_attrs | email: "other@example.com"}) refute duplicate.valid? assert "has already been taken" in errors_on(duplicate).callsign end test "surfaces unique constraint error on email at insert time (default opts)" do {:ok, _first} = %User{} |> User.registration_changeset(@valid_attrs) |> Repo.insert() # With the default opts, unique_constraint/3 is attached so the DB # violation comes back as a changeset error rather than raising. changeset = User.registration_changeset(%User{}, %{@valid_attrs | callsign: "W5XYZ"}) # unsafe_validate_unique already flags it at changeset-build time. assert "has already been taken" in errors_on(changeset).email end test "validate_unique: false skips callsign and email uniqueness checks" do {:ok, _first} = %User{} |> User.registration_changeset(@valid_attrs) |> Repo.insert() changeset = User.registration_changeset(%User{}, @valid_attrs, validate_unique: false) # Uniqueness is not pre-validated, so the changeset is otherwise valid. assert changeset.valid? end test "grants admin when email matches the admin_email constant (case-insensitive)" do attrs = Map.put(@valid_attrs, :email, "GRAHAM@MCINTIRE.ME") changeset = User.registration_changeset(%User{}, attrs) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :is_admin) == true end test "grants admin for exact admin email match" do attrs = Map.put(@valid_attrs, :email, "graham@mcintire.me") changeset = User.registration_changeset(%User{}, attrs) assert Ecto.Changeset.get_change(changeset, :is_admin) == true end test "does not grant admin for other emails" do changeset = User.registration_changeset(%User{}, @valid_attrs) refute Ecto.Changeset.get_change(changeset, :is_admin) end test "does not set is_admin when email is missing" do changeset = User.registration_changeset(%User{}, Map.delete(@valid_attrs, :email)) refute Ecto.Changeset.get_change(changeset, :is_admin) end end describe "admin_changeset/2" do setup do {:ok, user} = Repo.insert(User.registration_changeset(%User{}, @valid_attrs)) %{user: user} end test "accepts valid updates and casts is_admin", %{user: user} do changeset = User.admin_changeset(user, %{ callsign: "K5NEW", name: "New Name", email: "new@example.com", is_admin: true }) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :is_admin) == true assert Ecto.Changeset.get_change(changeset, :callsign) == "K5NEW" end test "trims and upcases callsign", %{user: user} do changeset = User.admin_changeset(user, %{callsign: " k5abc "}) assert Ecto.Changeset.get_change(changeset, :callsign) == "K5ABC" end test "requires callsign, name, and email", %{user: user} do changeset = User.admin_changeset(user, %{callsign: nil, name: nil, email: nil}) errors = errors_on(changeset) assert errors[:callsign] assert errors[:name] assert errors[:email] end test "rejects invalid callsign format", %{user: user} do changeset = User.admin_changeset(user, %{callsign: "bad-call"}) assert "must be 3-10 letters and digits" in errors_on(changeset).callsign end test "rejects invalid email", %{user: user} do changeset = User.admin_changeset(user, %{email: "bad email"}) assert "must have the @ sign and no spaces" in errors_on(changeset).email end test "does not cast password", %{user: user} do changeset = User.admin_changeset(user, %{password: "something1234"}) refute Ecto.Changeset.get_change(changeset, :password) refute Ecto.Changeset.get_change(changeset, :hashed_password) end test "enforces email uniqueness", %{user: user} do {:ok, _other} = Repo.insert( User.registration_changeset(%User{}, %{ @valid_attrs | callsign: "K5OTH", email: "other@example.com" }) ) changeset = User.admin_changeset(user, %{email: "other@example.com"}) refute changeset.valid? assert "has already been taken" in errors_on(changeset).email end test "enforces callsign uniqueness", %{user: user} do {:ok, _other} = Repo.insert( User.registration_changeset(%User{}, %{ @valid_attrs | callsign: "K5OTH", email: "other@example.com" }) ) changeset = User.admin_changeset(user, %{callsign: "K5OTH"}) refute changeset.valid? assert "has already been taken" in errors_on(changeset).callsign end end describe "email_changeset/3" do setup do {:ok, user} = Repo.insert(User.registration_changeset(%User{}, @valid_attrs)) %{user: user} end test "accepts a new valid email", %{user: user} do changeset = User.email_changeset(user, %{email: "new-address@example.com"}) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :email) == "new-address@example.com" end test "requires email", %{user: user} do changeset = User.email_changeset(user, %{email: nil}) assert errors_on(changeset).email end test "rejects malformed email", %{user: user} do changeset = User.email_changeset(user, %{email: "no-at-sign"}) assert "must have the @ sign and no spaces" in errors_on(changeset).email end test "rejects email longer than 160 chars", %{user: user} do long = String.duplicate("a", 161) <> "@example.com" changeset = User.email_changeset(user, %{email: long}) assert errors_on(changeset).email end test "errors if email did not change", %{user: user} do changeset = User.email_changeset(user, %{email: user.email}) assert "did not change" in errors_on(changeset).email end test "enforces email uniqueness against existing users", %{user: user} do {:ok, _other} = Repo.insert( User.registration_changeset(%User{}, %{ @valid_attrs | callsign: "K5OTH", email: "taken@example.com" }) ) changeset = User.email_changeset(user, %{email: "taken@example.com"}) refute changeset.valid? assert "has already been taken" in errors_on(changeset).email end test "validate_unique: false skips the uniqueness check", %{user: user} do {:ok, _other} = Repo.insert( User.registration_changeset(%User{}, %{ @valid_attrs | callsign: "K5OTH", email: "taken@example.com" }) ) changeset = User.email_changeset(user, %{email: "taken@example.com"}, validate_unique: false) assert changeset.valid? end end describe "password_changeset/3" do setup do {:ok, user} = Repo.insert(User.registration_changeset(%User{}, @valid_attrs)) %{user: user} end test "accepts a valid new password and hashes it by default", %{user: user} do changeset = User.password_changeset(user, %{ password: "new-password-123", password_confirmation: "new-password-123" }) assert changeset.valid? hashed = Ecto.Changeset.get_change(changeset, :hashed_password) assert is_binary(hashed) refute Ecto.Changeset.get_change(changeset, :password) end test "requires the password", %{user: user} do changeset = User.password_changeset(user, %{}) assert errors_on(changeset).password end test "rejects password shorter than 8 chars", %{user: user} do changeset = User.password_changeset(user, %{password: "short", password_confirmation: "short"}) assert errors_on(changeset).password end test "rejects password longer than 72 chars", %{user: user} do pw = String.duplicate("a", 73) changeset = User.password_changeset(user, %{password: pw, password_confirmation: pw}) assert errors_on(changeset).password end test "requires password_confirmation to match", %{user: user} do changeset = User.password_changeset(user, %{ password: "new-password-123", password_confirmation: "other-password-123" }) assert "does not match password" in errors_on(changeset).password_confirmation end test "hash_password: false keeps plaintext and skips hashing", %{user: user} do changeset = User.password_changeset( user, %{password: "new-password-123", password_confirmation: "new-password-123"}, hash_password: false ) assert changeset.valid? refute Ecto.Changeset.get_change(changeset, :hashed_password) assert Ecto.Changeset.get_change(changeset, :password) == "new-password-123" end end describe "confirm_changeset/1" do test "sets confirmed_at to the current UTC time (truncated to seconds)" do user = %User{} changeset = User.confirm_changeset(user) confirmed_at = Ecto.Changeset.get_change(changeset, :confirmed_at) assert %DateTime{} = confirmed_at assert confirmed_at.microsecond == {0, 0} assert DateTime.diff(DateTime.utc_now(), confirmed_at, :second) in 0..5 end end describe "valid_password?/2" do test "returns true when the password matches the hash" do user = user_fixture() assert User.valid_password?(user, valid_user_password()) end test "returns false for an incorrect password" do user = user_fixture() refute User.valid_password?(user, "wrong password") end test "returns false when hashed_password is nil" do refute User.valid_password?(%User{hashed_password: nil}, "anything") end test "returns false for empty password input" do user = user_fixture() refute User.valid_password?(user, "") end test "returns false for a nil user-like value" do refute User.valid_password?(nil, "anything") end end describe "change_home_qth/2" do test "derives lat/lon from a 6-char grid" do changeset = User.change_home_qth(%User{}, %{"home_grid" => "EM13qc"}) assert changeset.valid? lat = Ecto.Changeset.get_change(changeset, :home_lat) lon = Ecto.Changeset.get_change(changeset, :home_lon) assert_in_delta lat, 33.10, 0.05 assert_in_delta lon, -96.625, 0.05 assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc" end test "accepts explicit lat/lon without grid" do changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.0, "home_lon" => -96.7}) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :home_lat) == 33.0 assert Ecto.Changeset.get_change(changeset, :home_lon) == -96.7 end test "derives a 10-char home_grid when lat/lon are given without one" do changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.10, "home_lon" => -96.625}) assert changeset.valid? grid = Ecto.Changeset.get_change(changeset, :home_grid) assert is_binary(grid) assert String.length(grid) == 10 assert String.starts_with?(grid, "EM13qc") end test "preserves an explicit grid when both grid and lat/lon are provided" do changeset = User.change_home_qth(%User{}, %{ "home_grid" => "EM13qc", "home_lat" => 33.10, "home_lon" => -96.625 }) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc" end test "rejects invalid grids" do changeset = User.change_home_qth(%User{}, %{"home_grid" => "ZZ99zz"}) refute changeset.valid? assert errors_on(changeset)[:home_grid] end test "requires either grid or lat/lon" do changeset = User.change_home_qth(%User{}, %{}) refute changeset.valid? assert errors_on(changeset)[:home_grid] end test "rejects out-of-range lat/lon" do changeset = User.change_home_qth(%User{}, %{"home_lat" => 100.0, "home_lon" => 0.0}) refute changeset.valid? assert errors_on(changeset)[:home_lat] end test "uppercase Field + lowercase subsquare normalisation" do changeset = User.change_home_qth(%User{}, %{"home_grid" => "em13QC"}) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc" end test "clears all home fields when home_grid is an empty string" do user = %User{home_grid: "EM13qc", home_lat: 33.1, home_lon: -96.8, home_elevation_m: 200} changeset = User.change_home_qth(user, %{"home_grid" => "", "home_lat" => nil, "home_lon" => nil}) assert changeset.valid? assert Ecto.Changeset.get_change(changeset, :home_grid) == nil assert Ecto.Changeset.get_change(changeset, :home_lat) == nil assert Ecto.Changeset.get_change(changeset, :home_lon) == nil end end end