prop/test/microwaveprop/accounts/user_test.exs
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

521 lines
18 KiB
Elixir

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 byte_size(hashed) > 0
assert String.starts_with?(hashed, "$2b$")
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 byte_size(user.hashed_password) > 0
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 byte_size(hashed) > 0
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 byte_size(grid) > 0
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