diff --git a/test/aprsme/accounts/user_token_test.exs b/test/aprsme/accounts/user_token_test.exs new file mode 100644 index 0000000..6893e75 --- /dev/null +++ b/test/aprsme/accounts/user_token_test.exs @@ -0,0 +1,158 @@ +defmodule Aprsme.Accounts.UserTokenTest do + use Aprsme.DataCase, async: true + + import Aprsme.AccountsFixtures + + alias Aprsme.Accounts.UserToken + alias Aprsme.Repo + + describe "build_session_token/1" do + test "returns a binary token and a UserToken struct with context 'session'" do + user = user_fixture() + {token, user_token} = UserToken.build_session_token(user) + + assert is_binary(token) + assert byte_size(token) == 32 + assert user_token.token == token + assert user_token.context == "session" + assert user_token.user_id == user.id + assert is_nil(user_token.sent_to) + end + + test "produces unique tokens each call" do + user = user_fixture() + {t1, _} = UserToken.build_session_token(user) + {t2, _} = UserToken.build_session_token(user) + refute t1 == t2 + end + end + + describe "verify_session_token_query/1" do + test "returns the user when token is valid" do + user = user_fixture() + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + {:ok, query} = UserToken.verify_session_token_query(token) + loaded = Repo.one(query) + assert loaded.id == user.id + end + + test "returns nil for an unknown token" do + {:ok, query} = UserToken.verify_session_token_query(:crypto.strong_rand_bytes(32)) + assert Repo.one(query) == nil + end + end + + describe "build_email_token/2" do + test "returns a URL-safe base64 token and a hashed UserToken" do + user = user_fixture() + {encoded, user_token} = UserToken.build_email_token(user, "confirm") + + assert is_binary(encoded) + assert {:ok, _} = Base.url_decode64(encoded, padding: false) + assert user_token.context == "confirm" + assert user_token.sent_to == user.email + assert user_token.user_id == user.id + + # The stored token is a SHA-256 digest (32 bytes), not the encoded plaintext. + assert byte_size(user_token.token) == 32 + refute user_token.token == encoded + end + + test "supports reset_password context" do + user = user_fixture() + {_token, user_token} = UserToken.build_email_token(user, "reset_password") + assert user_token.context == "reset_password" + end + end + + describe "verify_email_token_query/2" do + test "returns :error for malformed tokens" do + assert UserToken.verify_email_token_query("not-base64!", "confirm") == :error + end + + test "returns {:ok, query} that finds the user for a valid token" do + user = user_fixture() + {encoded, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + + {:ok, query} = UserToken.verify_email_token_query(encoded, "confirm") + assert Repo.one(query).id == user.id + end + + test "returns a query matching no user for a valid-looking but unknown token" do + # Randomly-generated encoded token that doesn't exist in the DB. + encoded = 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + {:ok, query} = UserToken.verify_email_token_query(encoded, "confirm") + assert Repo.one(query) == nil + end + end + + describe "verify_change_email_token_query/2" do + test "returns :error for malformed tokens" do + assert UserToken.verify_change_email_token_query("not-base64!", "change:new@example.com") == :error + end + + test "accepts a well-formed token for a change: context" do + user = user_fixture() + # Reuse build_email_token/2 (same hashing) for a realistic token. + {encoded, user_token} = UserToken.build_email_token(user, "change:new@example.com") + Repo.insert!(user_token) + + {:ok, query} = UserToken.verify_change_email_token_query(encoded, "change:new@example.com") + # verify_change_email_token_query doesn't select :user, it selects tokens. + token_row = Repo.one(query) + assert token_row + assert token_row.context == "change:new@example.com" + end + end + + describe "token_and_context_query/2" do + test "returns a query scoped by token and context" do + user = user_fixture() + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + + query = UserToken.token_and_context_query(token, "session") + found = Repo.one(query) + assert found.token == token + assert found.context == "session" + end + + test "returns nothing for mismatched context" do + user = user_fixture() + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + + assert token |> UserToken.token_and_context_query("confirm") |> Repo.one() == nil + end + end + + describe "user_and_contexts_query/2" do + setup do + user = user_fixture() + {_token, session_token} = UserToken.build_session_token(user) + {_token, confirm_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(session_token) + Repo.insert!(confirm_token) + {:ok, user: user} + end + + test "with :all returns every token for the user", %{user: user} do + tokens = user |> UserToken.user_and_contexts_query(:all) |> Repo.all() + assert length(tokens) == 2 + contexts = tokens |> Enum.map(& &1.context) |> Enum.sort() + assert contexts == ["confirm", "session"] + end + + test "with a specific context list filters", %{user: user} do + tokens = user |> UserToken.user_and_contexts_query(["session"]) |> Repo.all() + assert length(tokens) == 1 + assert hd(tokens).context == "session" + end + + test "with an unmatched context returns empty", %{user: user} do + assert user |> UserToken.user_and_contexts_query(["reset_password"]) |> Repo.all() == [] + end + end +end diff --git a/test/aprsme/callsign_test.exs b/test/aprsme/callsign_test.exs index 98fc377..2a646e1 100644 --- a/test/aprsme/callsign_test.exs +++ b/test/aprsme/callsign_test.exs @@ -54,5 +54,95 @@ defmodule Aprsme.CallsignTest do test "handles nil input" do assert Callsign.normalize(nil) == "" end + + test "returns empty string for non-binary non-nil input" do + assert Callsign.normalize(123) == "" + assert Callsign.normalize(%{}) == "" + end + end + + describe "matches?/2" do + test "matches identical normalized callsigns" do + assert Callsign.matches?("K5ABC", "K5ABC") + assert Callsign.matches?("k5abc", "K5ABC") + assert Callsign.matches?(" K5ABC ", "k5abc") + end + + test "distinguishes different callsigns" do + refute Callsign.matches?("K5ABC", "K5ABD") + refute Callsign.matches?("K5ABC-1", "K5ABC-2") + end + + test "treats nil as empty string" do + assert Callsign.matches?(nil, nil) + refute Callsign.matches?(nil, "K5ABC") + refute Callsign.matches?("K5ABC", nil) + end + end + + describe "extract_base/1" do + test "returns base callsign before last hyphen" do + assert Callsign.extract_base("K5ABC-9") == "K5ABC" + assert Callsign.extract_base("W1XYZ-15") == "W1XYZ" + end + + test "preserves intermediate hyphens, splitting on the last one" do + assert Callsign.extract_base("VE-KTKI-1") == "VE-KTKI" + end + + test "returns the callsign unchanged when there's no hyphen" do + assert Callsign.extract_base("K5ABC") == "K5ABC" + end + + test "handles nil and non-binary input" do + assert Callsign.extract_base(nil) == "" + assert Callsign.extract_base(123) == "" + end + end + + describe "extract_ssid/1" do + test "returns the trailing SSID" do + assert Callsign.extract_ssid("K5ABC-9") == "9" + assert Callsign.extract_ssid("K5ABC-15") == "15" + end + + test "returns the last hyphen segment when multiple are present" do + assert Callsign.extract_ssid("VE-KTKI-1") == "1" + end + + test "returns '0' when no SSID" do + assert Callsign.extract_ssid("K5ABC") == "0" + end + + test "handles nil and non-binary input" do + assert Callsign.extract_ssid(nil) == "0" + assert Callsign.extract_ssid(123) == "0" + end + end + + describe "extract_parts/1" do + test "splits base and SSID for callsign with SSID" do + assert Callsign.extract_parts("K5ABC-9") == {"K5ABC", "9"} + end + + test "returns '0' SSID when no hyphen" do + assert Callsign.extract_parts("K5ABC") == {"K5ABC", "0"} + end + + test "handles base callsign with internal hyphen" do + assert Callsign.extract_parts("VE-KTKI-1") == {"VE-KTKI", "1"} + end + + test "handles nil and non-binary input" do + assert Callsign.extract_parts(nil) == {"", "0"} + assert Callsign.extract_parts(123) == {"", "0"} + end + + test "is consistent with extract_base/1 and extract_ssid/1" do + for input <- ["K5ABC", "K5ABC-9", "VE-KTKI-1"] do + assert Callsign.extract_parts(input) == + {Callsign.extract_base(input), Callsign.extract_ssid(input)} + end + end end end diff --git a/test/aprsme/cleanup_scheduler_test.exs b/test/aprsme/cleanup_scheduler_test.exs new file mode 100644 index 0000000..5fc8e6f --- /dev/null +++ b/test/aprsme/cleanup_scheduler_test.exs @@ -0,0 +1,52 @@ +defmodule Aprsme.CleanupSchedulerTest do + # Mutates :aprsme, :cleanup_scheduler env; must be serialized with anything + # else that reads it. + use ExUnit.Case, async: false + + alias Aprsme.CleanupScheduler + + setup do + original = Application.get_env(:aprsme, :cleanup_scheduler) + on_exit(fn -> Application.put_env(:aprsme, :cleanup_scheduler, original || []) end) + :ok + end + + describe "init/1" do + test "returns default 6-hour interval when enabled" do + Application.put_env(:aprsme, :cleanup_scheduler, enabled: true) + assert {:ok, %{interval: interval}} = CleanupScheduler.init([]) + assert interval == 6 * 60 * 60 * 1000 + end + + test "honors a configured interval" do + Application.put_env(:aprsme, :cleanup_scheduler, enabled: true, interval: 5_000) + assert {:ok, %{interval: 5_000}} = CleanupScheduler.init([]) + end + + test "returns nil interval when disabled" do + Application.put_env(:aprsme, :cleanup_scheduler, enabled: false) + assert {:ok, %{interval: nil}} = CleanupScheduler.init([]) + end + + test "defaults to enabled when no config" do + Application.put_env(:aprsme, :cleanup_scheduler, []) + assert {:ok, %{interval: interval}} = CleanupScheduler.init([]) + assert is_integer(interval) + end + + test "init schedules a follow-up :schedule_cleanup message when enabled" do + Application.put_env(:aprsme, :cleanup_scheduler, enabled: true, interval: 10) + {:ok, _state} = CleanupScheduler.init([]) + # Process.send_after targets self() (the test process in this call context), + # so we should receive the scheduled message shortly. + assert_receive :schedule_cleanup, 100 + end + end + + describe "handle_info/2" do + test "no-op for :schedule_cleanup when disabled" do + state = %{interval: nil} + assert CleanupScheduler.handle_info(:schedule_cleanup, state) == {:noreply, state} + end + end +end diff --git a/test/aprsme/convert_test.exs b/test/aprsme/convert_test.exs index d421779..e16efa1 100644 --- a/test/aprsme/convert_test.exs +++ b/test/aprsme/convert_test.exs @@ -1,5 +1,6 @@ defmodule Aprsme.ConvertTest do use ExUnit.Case, async: true + use ExUnitProperties alias Aprsme.Convert @@ -7,17 +8,93 @@ defmodule Aprsme.ConvertTest do test "converts ultimeter mph" do assert Convert.wind(55, :ultimeter, :mph) == 3.417541556 end + + test "zero stays zero" do + assert Convert.wind(0, :ultimeter, :mph) == 0.0 + end end describe "temp/3" do test "converts ultimeter f" do assert Convert.temp(55, :ultimeter, :f) == 5.5 end + + test "zero stays zero" do + assert Convert.temp(0, :ultimeter, :f) == 0.0 + end end describe "speed/3" do test "converts knots to mph" do assert Convert.speed(55, :knots, :mph) == 63.29 end + + test "zero knots is zero mph" do + assert Convert.speed(0, :knots, :mph) == 0.0 + end + + test "rounds to two decimal places" do + # 10 knots * 1.15077945 = 11.5077945, rounded = 11.51 + assert Convert.speed(10, :knots, :mph) == 11.51 + end + end + + describe "f_to_c/1 and c_to_f/1" do + test "converts freezing point" do + assert Convert.f_to_c(32) == 0.0 + assert Convert.c_to_f(0) == 32.0 + end + + test "converts boiling point" do + assert_in_delta Convert.f_to_c(212), 100.0, 0.0001 + assert_in_delta Convert.c_to_f(100), 212.0, 0.0001 + end + + test "-40 is the same in both scales" do + assert_in_delta Convert.f_to_c(-40), -40.0, 0.0001 + assert_in_delta Convert.c_to_f(-40), -40.0, 0.0001 + end + + property "f_to_c and c_to_f are inverses" do + check all(f <- float(min: -200.0, max: 300.0)) do + assert_in_delta Convert.c_to_f(Convert.f_to_c(f)), f, 0.0001 + end + end + end + + describe "mph_to_kph/1 and kph_to_mph/1" do + test "converts known value" do + assert_in_delta Convert.mph_to_kph(1), 1.60934, 0.0001 + assert_in_delta Convert.kph_to_mph(1), 0.6214, 0.001 + end + + test "zero stays zero" do + assert Convert.mph_to_kph(0) == 0.0 + assert Convert.kph_to_mph(0) == 0.0 + end + + property "mph_to_kph and kph_to_mph are inverses" do + check all(v <- float(min: 0.0, max: 1000.0)) do + assert_in_delta Convert.kph_to_mph(Convert.mph_to_kph(v)), v, 0.0001 + end + end + end + + describe "inches_to_mm/1 and mm_to_inches/1" do + test "one inch is 25.4 mm" do + assert Convert.inches_to_mm(1) == 25.4 + assert_in_delta Convert.mm_to_inches(25.4), 1.0, 0.0001 + end + + test "zero stays zero" do + assert Convert.inches_to_mm(0) == 0.0 + assert Convert.mm_to_inches(0) == 0.0 + end + + property "inches_to_mm and mm_to_inches are inverses" do + check all(v <- float(min: 0.0, max: 1000.0)) do + assert_in_delta Convert.mm_to_inches(Convert.inches_to_mm(v)), v, 0.0001 + end + end end end diff --git a/test/aprsme/deployment_notifier_test.exs b/test/aprsme/deployment_notifier_test.exs new file mode 100644 index 0000000..44e15a6 --- /dev/null +++ b/test/aprsme/deployment_notifier_test.exs @@ -0,0 +1,43 @@ +defmodule Aprsme.DeploymentNotifierTest do + use ExUnit.Case, async: false + + alias Aprsme.DeploymentNotifier + + describe "notify_deployment/1" do + test "broadcasts :new_deployment over Aprsme.PubSub" do + :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events") + now = DateTime.utc_now() + + assert :ok == DeploymentNotifier.notify_deployment(now) + + assert_receive {:new_deployment, %{deployed_at: ^now}}, 500 + end + end + + describe "handle_info :check_deployment" do + test "no-op when deployed_at is unchanged" do + deployed = DateTime.utc_now() + Application.put_env(:aprsme, :deployed_at, deployed) + + state = %{deployed_at: deployed} + assert {:noreply, ^state} = DeploymentNotifier.handle_info(:check_deployment, state) + end + + test "broadcasts and updates state when deployed_at has changed" do + :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events") + + new_deploy = DateTime.utc_now() + old_deploy = DateTime.add(new_deploy, -60, :second) + + # Simulate an updated deploy timestamp in app env. + Application.put_env(:aprsme, :deployed_at, new_deploy) + + state = %{deployed_at: old_deploy} + + assert {:noreply, %{deployed_at: ^new_deploy}} = + DeploymentNotifier.handle_info(:check_deployment, state) + + assert_receive {:new_deployment, %{deployed_at: ^new_deploy}}, 500 + end + end +end diff --git a/test/aprsme/device_identification_test.exs b/test/aprsme/device_identification_test.exs index e28ed16..c682b55 100644 --- a/test/aprsme/device_identification_test.exs +++ b/test/aprsme/device_identification_test.exs @@ -94,6 +94,45 @@ defmodule Aprsme.DeviceIdentificationTest do end end + describe "upsert_devices/1" do + test "replaces the devices table with data from the JSON" do + # Seed some dummy state to be wiped. + Repo.delete_all(Devices) + + Repo.insert!(%Devices{ + identifier: "WILLBEWIPED", + vendor: "Old", + model: "Old" + }) + + json = %{ + "tocalls" => %{ + "APXYZ" => %{"vendor" => "NewVendor", "model" => "NewModel", "features" => ["a", "b"]} + }, + "mice" => %{ + "]=" => %{"vendor" => "KenwoodMice", "model" => "TH-D7", "features" => "single"} + } + } + + assert :ok == DeviceIdentification.upsert_devices(json) + + # Previous row is gone, new rows inserted with correct attrs. + assert Repo.get_by(Devices, identifier: "WILLBEWIPED") == nil + + assert %Devices{vendor: "NewVendor", model: "NewModel", features: ["a", "b"]} = + Repo.get_by(Devices, identifier: "APXYZ") + + # Single-string feature gets wrapped into a list by process_device_attrs. + assert %Devices{features: ["single"]} = Repo.get_by(Devices, identifier: "]=") + end + + test "handles empty JSON groups" do + Repo.delete_all(Devices) + assert :ok == DeviceIdentification.upsert_devices(%{}) + assert Repo.all(Devices) == [] + end + end + describe "lookup_device_by_identifier/1" do test "matches APSK21 to APS??? pattern" do # Seed the devices table from the JSON @@ -114,6 +153,31 @@ defmodule Aprsme.DeviceIdentificationTest do assert found.vendor end + test "handles non-wildcard exact match" do + # Insert a device with an exact identifier (no ?). + Repo.delete_all(Devices) + + Repo.insert!(%Devices{ + identifier: "EXACTMATCH", + vendor: "Vendor", + model: "Model" + }) + + # Refresh the cache so lookups see the new record. + devices = Repo.all(Devices) + Aprsme.Cache.put(:device_cache, :all_devices, devices) + + found = DeviceIdentification.lookup_device_by_identifier("EXACTMATCH") + assert found + assert found.identifier == "EXACTMATCH" + + refute DeviceIdentification.lookup_device_by_identifier("NOTEXACTMATCH") + end + + test "returns nil for nil input" do + assert DeviceIdentification.lookup_device_by_identifier(nil) == nil + end + test "matches Mic-E device identifier from raw packet" do # Seed the devices table from the JSON Aprsme.DevicesSeeder.seed_from_json() diff --git a/test/aprsme/device_parser_test.exs b/test/aprsme/device_parser_test.exs new file mode 100644 index 0000000..10550e5 --- /dev/null +++ b/test/aprsme/device_parser_test.exs @@ -0,0 +1,114 @@ +defmodule Aprsme.DeviceParserTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Aprsme.DeviceParser + + describe "extract_device_identifier/1" do + test "returns the device_identifier atom-key value first" do + assert DeviceParser.extract_device_identifier(%{device_identifier: "APRS123"}) == "APRS123" + end + + test "returns the device_identifier string-key value" do + assert DeviceParser.extract_device_identifier(%{"device_identifier" => "APRS123"}) == + "APRS123" + end + + test "falls back to destination when device_identifier missing" do + assert DeviceParser.extract_device_identifier(%{destination: "APN382"}) == "APN382" + assert DeviceParser.extract_device_identifier(%{"destination" => "APN382"}) == "APN382" + end + + test "prefers device_identifier over destination" do + packet = %{device_identifier: "DIRECT", destination: "APN382"} + assert DeviceParser.extract_device_identifier(packet) == "DIRECT" + end + + test "falls back to symbol table/code from atom-keyed data_extended" do + packet = %{data_extended: %{symbol_table_id: "/", symbol_code: ">"}} + assert DeviceParser.extract_device_identifier(packet) == "/>" + end + + test "falls back to symbol table/code from string-keyed data_extended" do + packet = %{"data_extended" => %{"symbol_table_id" => "\\", "symbol_code" => "k"}} + assert DeviceParser.extract_device_identifier(packet) == "\\k" + end + + test "prefers destination over data_extended symbol (destination clause matches first)" do + # Pattern-match order in find_device_identifier/1 checks destination before data_extended. + packet = %{ + data_extended: %{symbol_table_id: "/", symbol_code: ">"}, + destination: "APN382" + } + + assert DeviceParser.extract_device_identifier(packet) == "APN382" + end + + test "falls back to data_extended symbol when no destination is set" do + packet = %{data_extended: %{symbol_table_id: "/", symbol_code: ">"}} + assert DeviceParser.extract_device_identifier(packet) == "/>" + end + + test "returns nil for empty device_identifier strings" do + assert DeviceParser.extract_device_identifier(%{device_identifier: ""}) == nil + end + + test "returns nil for empty destination strings" do + assert DeviceParser.extract_device_identifier(%{destination: ""}) == nil + end + + test "returns nil for empty map" do + assert DeviceParser.extract_device_identifier(%{}) == nil + end + + test "returns nil for non-map input" do + assert DeviceParser.extract_device_identifier(nil) == nil + assert DeviceParser.extract_device_identifier("hello") == nil + assert DeviceParser.extract_device_identifier(42) == nil + assert DeviceParser.extract_device_identifier([]) == nil + end + + test "returns nil when device_identifier is a non-binary truthy value" do + assert DeviceParser.extract_device_identifier(%{device_identifier: :atom}) == nil + assert DeviceParser.extract_device_identifier(%{device_identifier: 123}) == nil + end + end + + describe "normalize_device_identifier/1" do + test "trims whitespace from strings" do + assert DeviceParser.normalize_device_identifier(" APRS123 ") == "APRS123" + end + + test "leaves clean strings alone" do + assert DeviceParser.normalize_device_identifier("APRS123") == "APRS123" + end + + test "converts atoms to strings" do + assert DeviceParser.normalize_device_identifier(:yaesu_ft1d) == "yaesu_ft1d" + end + + test "returns nil for nil" do + assert DeviceParser.normalize_device_identifier(nil) == nil + end + + test "returns nil for unsupported types" do + assert DeviceParser.normalize_device_identifier(123) == nil + assert DeviceParser.normalize_device_identifier(%{}) == nil + assert DeviceParser.normalize_device_identifier([]) == nil + end + + property "strings round-trip through normalization without surrounding whitespace" do + check all(s <- string(:alphanumeric, min_length: 1)) do + assert DeviceParser.normalize_device_identifier(s) == s + assert DeviceParser.normalize_device_identifier(" " <> s <> " ") == s + end + end + + property "atom inputs normalize to their string form" do + check all(s <- string(:alphanumeric, min_length: 1, max_length: 10)) do + atom = String.to_atom(s) + assert DeviceParser.normalize_device_identifier(atom) == s + end + end + end +end diff --git a/test/aprsme/log_sanitizer_test.exs b/test/aprsme/log_sanitizer_test.exs new file mode 100644 index 0000000..dc622b4 --- /dev/null +++ b/test/aprsme/log_sanitizer_test.exs @@ -0,0 +1,258 @@ +defmodule Aprsme.LogSanitizerTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Aprsme.LogSanitizer + alias Aprsme.Support.FakeSensitiveStruct + + describe "sanitize_map/1" do + test "redacts known sensitive atom keys" do + result = LogSanitizer.sanitize_map(%{password: "hunter2", user: "alice"}) + assert result[:password] == "[REDACTED]" + assert result[:user] == "alice" + end + + test "redacts known sensitive string keys" do + result = LogSanitizer.sanitize_map(%{"secret" => "xyz", "other" => "keep"}) + assert result["secret"] == "[REDACTED]" + assert result["other"] == "keep" + end + + test "leaves maps without sensitive keys alone" do + map = %{foo: 1, bar: "baz"} + assert LogSanitizer.sanitize_map(map) == map + end + + test "redacts every sensitive field when all present" do + fields = [ + :password, + :passcode, + :auth_token, + :api_key, + :secret, + :token, + :authorization, + :csrf_token, + :session_token, + :private_key, + :database_url, + :secret_key_base, + :signing_salt + ] + + input = Map.new(fields, fn f -> {f, "value"} end) + result = LogSanitizer.sanitize_map(input) + + for f <- fields do + assert result[f] == "[REDACTED]", "expected #{inspect(f)} to be redacted" + end + end + + test "returns non-map input unchanged" do + assert LogSanitizer.sanitize_map("hello") == "hello" + assert LogSanitizer.sanitize_map(nil) == nil + assert LogSanitizer.sanitize_map(42) == 42 + end + end + + describe "sanitize_string/1" do + test "redacts patterns like password=secret" do + assert LogSanitizer.sanitize_string("password=hunter2") == "password=[REDACTED]" + end + + test "redacts patterns like token: abc" do + # Pattern is "token[=:]\s*\S+" — `:` followed by non-whitespace. + result = LogSanitizer.sanitize_string("token:abc123") + assert result == "token=[REDACTED]" + end + + test "redacts multiple occurrences in a single string" do + input = "password=foo secret=bar" + result = LogSanitizer.sanitize_string(input) + assert result =~ "password=[REDACTED]" + assert result =~ "secret=[REDACTED]" + end + + test "is case-insensitive" do + assert LogSanitizer.sanitize_string("PASSWORD=X") =~ "[REDACTED]" + assert LogSanitizer.sanitize_string("Token=Y") =~ "[REDACTED]" + end + + test "returns non-binary input unchanged" do + assert LogSanitizer.sanitize_string(nil) == nil + assert LogSanitizer.sanitize_string(42) == 42 + assert LogSanitizer.sanitize_string(%{}) == %{} + end + + test "leaves strings without sensitive patterns alone" do + assert LogSanitizer.sanitize_string("all clear here") == "all clear here" + end + end + + describe "sanitize/1 (recursive)" do + test "walks nested maps" do + input = %{ + outer: %{password: "x", inner: %{token: "y", keep: "ok"}} + } + + result = LogSanitizer.sanitize(input) + assert result.outer.password == "[REDACTED]" + assert result.outer.inner.token == "[REDACTED]" + assert result.outer.inner.keep == "ok" + end + + test "walks lists" do + input = [%{password: "x"}, %{keep: 1}, "password=y"] + [first, second, third] = LogSanitizer.sanitize(input) + assert first.password == "[REDACTED]" + assert second == %{keep: 1} + assert third == "password=[REDACTED]" + end + + test "sanitizes exception structs via Exception.message/1" do + exception = %RuntimeError{message: "failed with password=abc"} + result = LogSanitizer.sanitize(exception) + assert is_binary(result) + assert result =~ "[REDACTED]" + refute result =~ "abc" + end + + test "converts non-exception structs and sanitizes them" do + result = LogSanitizer.sanitize(%FakeSensitiveStruct{password: "x", name: "ok"}) + assert result[:password] == "[REDACTED]" + assert result[:name] == "ok" + end + + test "passes through scalar values" do + assert LogSanitizer.sanitize(42) == 42 + assert LogSanitizer.sanitize(:atom) == :atom + assert LogSanitizer.sanitize(true) == true + assert LogSanitizer.sanitize(nil) == nil + end + end + + describe "sanitize_packet_data/1" do + test "drops raw_packet, comment, status, and data_extended" do + input = %{ + raw_packet: "should be gone", + comment: "also gone", + status: "gone", + data_extended: %{foo: :bar}, + sender: "K5ABC" + } + + result = LogSanitizer.sanitize_packet_data(input) + refute Map.has_key?(result, :raw_packet) + refute Map.has_key?(result, :comment) + refute Map.has_key?(result, :status) + refute Map.has_key?(result, :data_extended) + assert result[:sender] == "K5ABC" + end + + test "also redacts sensitive keys that sneak through" do + result = LogSanitizer.sanitize_packet_data(%{password: "x", sender: "K5ABC"}) + assert result[:password] == "[REDACTED]" + end + + test "returns non-map input unchanged" do + assert LogSanitizer.sanitize_packet_data("raw string") == "raw string" + end + end + + describe "sanitize_connection_info/1" do + test "always redacts password and passcode even if missing" do + result = LogSanitizer.sanitize_connection_info(%{host: "rotate.aprs.net"}) + assert result[:password] == "[REDACTED]" + assert result[:passcode] == "[REDACTED]" + assert result[:host] == "rotate.aprs.net" + end + + test "redacts pre-existing password values" do + result = LogSanitizer.sanitize_connection_info(%{password: "real", passcode: "12345"}) + assert result[:password] == "[REDACTED]" + assert result[:passcode] == "[REDACTED]" + end + + test "returns non-map input unchanged" do + assert LogSanitizer.sanitize_connection_info(nil) == nil + end + end + + describe "sanitize_error_context/1" do + test "truncates stacktraces longer than 5 entries" do + stacktrace = Enum.map(1..10, fn i -> {:module, :fun, [i], []} end) + result = LogSanitizer.sanitize_error_context(%{stacktrace: stacktrace}) + assert length(result[:stacktrace]) == 5 + end + + test "leaves stacktraces under 5 entries alone" do + stacktrace = [{:module, :fun, [], []}] + result = LogSanitizer.sanitize_error_context(%{stacktrace: stacktrace}) + assert result[:stacktrace] == stacktrace + end + + test "still sanitizes sensitive keys" do + result = LogSanitizer.sanitize_error_context(%{password: "x", stacktrace: []}) + assert result[:password] == "[REDACTED]" + end + end + + describe "log_data/1" do + test "sanitizes each value in a keyword list" do + result = LogSanitizer.log_data(user: "alice", config: %{secret: "x"}) + assert result[:user] == "alice" + assert result[:config][:secret] == "[REDACTED]" + end + + test "falls back to sanitize/1 for non-keyword input" do + assert LogSanitizer.log_data(%{password: "x"})[:password] == "[REDACTED]" + assert LogSanitizer.log_data("password=x") == "password=[REDACTED]" + end + end + + describe "property: sanitize/1 is idempotent" do + property "running sanitize twice equals running it once on maps" do + check all(map <- map_of(atom(:alphanumeric), term())) do + once = LogSanitizer.sanitize(map) + twice = LogSanitizer.sanitize(once) + assert once == twice + end + end + + property "running sanitize_string twice equals running it once" do + check all(string <- string(:printable)) do + once = LogSanitizer.sanitize_string(string) + twice = LogSanitizer.sanitize_string(once) + assert once == twice + end + end + end + + describe "property: sensitive values never leak through" do + property "any value stored under a sensitive atom key is replaced" do + sensitive_fields = [ + :password, + :passcode, + :auth_token, + :api_key, + :secret, + :token, + :authorization, + :csrf_token, + :session_token, + :private_key, + :database_url, + :secret_key_base, + :signing_salt + ] + + check all( + field <- member_of(sensitive_fields), + value <- string(:printable, min_length: 1) + ) do + result = LogSanitizer.sanitize_map(%{field => value}) + assert result[field] == "[REDACTED]" + end + end + end +end diff --git a/test/aprsme/packet_field_whitelist_test.exs b/test/aprsme/packet_field_whitelist_test.exs new file mode 100644 index 0000000..0676ec5 --- /dev/null +++ b/test/aprsme/packet_field_whitelist_test.exs @@ -0,0 +1,130 @@ +defmodule Aprsme.PacketFieldWhitelistTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Aprsme.PacketFieldWhitelist + + describe "allowed_fields/0" do + test "returns a list of strings" do + fields = PacketFieldWhitelist.allowed_fields() + assert is_list(fields) + assert match?([_ | _], fields) + assert Enum.all?(fields, &is_binary/1) + end + + test "contains core schema fields" do + fields = PacketFieldWhitelist.allowed_fields() + assert "sender" in fields + assert "base_callsign" in fields + assert "lat" in fields + assert "lon" in fields + assert "received_at" in fields + assert "raw_packet" in fields + end + end + + describe "allowed?/1" do + test "accepts atoms that match allowed fields" do + assert PacketFieldWhitelist.allowed?(:sender) + assert PacketFieldWhitelist.allowed?(:raw_packet) + assert PacketFieldWhitelist.allowed?(:lat) + end + + test "accepts strings that match allowed fields" do + assert PacketFieldWhitelist.allowed?("sender") + assert PacketFieldWhitelist.allowed?("raw_packet") + end + + test "rejects unknown atoms" do + refute PacketFieldWhitelist.allowed?(:totally_bogus) + refute PacketFieldWhitelist.allowed?(:__struct__) + end + + test "rejects unknown strings" do + refute PacketFieldWhitelist.allowed?("id") + refute PacketFieldWhitelist.allowed?("") + end + + test "rejects non-atom, non-binary input" do + refute PacketFieldWhitelist.allowed?(123) + refute PacketFieldWhitelist.allowed?(nil) + refute PacketFieldWhitelist.allowed?(%{}) + refute PacketFieldWhitelist.allowed?([]) + end + end + + describe "filter_fields/1" do + test "keeps allowed atom keys" do + input = %{sender: "K5ABC", lat: 30.0, bogus: "x"} + result = PacketFieldWhitelist.filter_fields(input) + assert result[:sender] == "K5ABC" + assert result[:lat] == 30.0 + refute Map.has_key?(result, :bogus) + end + + test "keeps allowed string keys" do + input = %{"sender" => "K5ABC", "unknown" => "x"} + result = PacketFieldWhitelist.filter_fields(input) + assert result["sender"] == "K5ABC" + refute Map.has_key?(result, "unknown") + end + + test "preserves whichever key type caller used" do + atom_in = %{sender: "a"} + string_in = %{"sender" => "b"} + assert PacketFieldWhitelist.filter_fields(atom_in) == %{sender: "a"} + assert PacketFieldWhitelist.filter_fields(string_in) == %{"sender" => "b"} + end + + test "returns empty map when no keys are allowed" do + assert PacketFieldWhitelist.filter_fields(%{bogus: 1, junk: 2}) == %{} + end + + test "returns empty map on empty input" do + assert PacketFieldWhitelist.filter_fields(%{}) == %{} + end + end + + describe "invalid_fields/1" do + test "returns only the disallowed keys as strings" do + input = %{"junk" => 2, sender: "x", bogus: 1} + assert PacketFieldWhitelist.invalid_fields(input) == ["bogus", "junk"] + end + + test "returns empty list when every key is allowed" do + assert PacketFieldWhitelist.invalid_fields(%{sender: "x", lat: 1.0}) == [] + end + + test "returns sorted, deduplicated-looking output" do + input = %{z_bogus: 1, a_bogus: 2} + result = PacketFieldWhitelist.invalid_fields(input) + assert result == Enum.sort(result) + end + end + + describe "property: filter_fields/1 is idempotent" do + property "running filter twice equals running once" do + check all(map <- map_of(atom(:alphanumeric), term())) do + once = PacketFieldWhitelist.filter_fields(map) + twice = PacketFieldWhitelist.filter_fields(once) + assert once == twice + end + end + + property "every key in the output satisfies allowed?/1" do + check all(map <- map_of(atom(:alphanumeric), term())) do + result = PacketFieldWhitelist.filter_fields(map) + assert Enum.all?(Map.keys(result), &PacketFieldWhitelist.allowed?/1) + end + end + + property "invalid_fields and filter_fields partition the input" do + check all(map <- map_of(atom(:alphanumeric), term())) do + kept = map |> PacketFieldWhitelist.filter_fields() |> Map.keys() |> Enum.map(&to_string/1) + dropped = PacketFieldWhitelist.invalid_fields(map) + all_keys = map |> Map.keys() |> Enum.map(&to_string/1) + assert Enum.sort(kept ++ dropped) == Enum.sort(all_keys) + end + end + end +end diff --git a/test/aprsme/packet_sanitizer_test.exs b/test/aprsme/packet_sanitizer_test.exs new file mode 100644 index 0000000..eb3dfe9 Binary files /dev/null and b/test/aprsme/packet_sanitizer_test.exs differ diff --git a/test/aprsme/regex_cache_test.exs b/test/aprsme/regex_cache_test.exs new file mode 100644 index 0000000..b736e3a --- /dev/null +++ b/test/aprsme/regex_cache_test.exs @@ -0,0 +1,59 @@ +defmodule Aprsme.RegexCacheTest do + # async: false — the cache is a singleton GenServer owning a named ETS table + # shared across the whole application. Parallel tests would see each other. + use ExUnit.Case, async: false + + alias Aprsme.RegexCache + + # Each test gets a unique pattern to avoid cross-test pollution of the + # shared cache (we can't safely clear it without racing other callers). + defp unique_pattern(tag) do + suffix = System.unique_integer([:positive, :monotonic]) + "#{tag}_#{suffix}" + end + + describe "get_or_compile/1" do + test "compiles and returns a Regex on first call" do + pattern = unique_pattern("hello") + assert {:ok, regex} = RegexCache.get_or_compile(pattern) + assert %Regex{} = regex + assert Regex.match?(regex, pattern) + end + + test "returns the same compiled regex on subsequent calls" do + pattern = unique_pattern("same") + assert {:ok, r1} = RegexCache.get_or_compile(pattern) + assert {:ok, r2} = RegexCache.get_or_compile(pattern) + assert r1 == r2 + end + + test "returns {:error, _} for invalid patterns" do + assert {:error, _reason} = RegexCache.get_or_compile("[") + assert {:error, _reason} = RegexCache.get_or_compile("(unbalanced") + end + + test "does not cache failed compilations" do + assert {:error, _} = RegexCache.get_or_compile("[bad") + # Still an error on retry — error path didn't poison the cache with nil. + assert {:error, _} = RegexCache.get_or_compile("[bad") + end + + test "distinct patterns produce distinct regexes" do + p1 = unique_pattern("a") + p2 = unique_pattern("b") + assert {:ok, r1} = RegexCache.get_or_compile(p1) + assert {:ok, r2} = RegexCache.get_or_compile(p2) + refute r1 == r2 + end + + test "compiled regex actually matches as expected" do + pattern = unique_pattern("match") <> "\\d+" + assert {:ok, regex} = RegexCache.get_or_compile(pattern) + + base = String.trim_trailing(pattern, "\\d+") + + assert Regex.match?(regex, "#{base}123") + refute Regex.match?(regex, "nope") + end + end +end diff --git a/test/aprsme/release_test.exs b/test/aprsme/release_test.exs new file mode 100644 index 0000000..2f981d4 --- /dev/null +++ b/test/aprsme/release_test.exs @@ -0,0 +1,63 @@ +defmodule Aprsme.ReleaseTest do + # Mutates DEPLOYED_AT env var and :aprsme, :deployed_at — can't run in parallel. + use ExUnit.Case, async: false + + alias Aprsme.Release + + setup do + original_env = System.get_env("DEPLOYED_AT") + original_config = Application.get_env(:aprsme, :deployed_at) + + on_exit(fn -> + if is_nil(original_env) do + System.delete_env("DEPLOYED_AT") + else + System.put_env("DEPLOYED_AT", original_env) + end + + Application.put_env(:aprsme, :deployed_at, original_config) + end) + + :ok + end + + describe "init/0" do + test "returns DateTime and stores it in app env when DEPLOYED_AT is unset" do + System.delete_env("DEPLOYED_AT") + Application.delete_env(:aprsme, :deployed_at) + + result = Release.init() + assert %DateTime{} = result + assert Application.get_env(:aprsme, :deployed_at) == result + end + + test "parses valid ISO8601 DEPLOYED_AT" do + System.put_env("DEPLOYED_AT", "2024-01-15T12:00:00Z") + result = Release.init() + assert DateTime.to_iso8601(result) == "2024-01-15T12:00:00Z" + end + + test "falls back to current time for invalid DEPLOYED_AT" do + System.put_env("DEPLOYED_AT", "not-a-timestamp") + before = DateTime.utc_now() + result = Release.init() + assert %DateTime{} = result + assert DateTime.compare(result, before) in [:gt, :eq] + end + end + + describe "deployed_at/0" do + test "returns the stored value when set" do + dt = ~U[2024-06-01 00:00:00Z] + Application.put_env(:aprsme, :deployed_at, dt) + assert Release.deployed_at() == dt + end + + test "initializes to current time when unset" do + Application.delete_env(:aprsme, :deployed_at) + result = Release.deployed_at() + assert %DateTime{} = result + assert Application.get_env(:aprsme, :deployed_at) == result + end + end +end diff --git a/test/aprsme_web/controllers/api/v1/fallback_controller_test.exs b/test/aprsme_web/controllers/api/v1/fallback_controller_test.exs new file mode 100644 index 0000000..11709cc --- /dev/null +++ b/test/aprsme_web/controllers/api/v1/fallback_controller_test.exs @@ -0,0 +1,78 @@ +defmodule AprsmeWeb.Api.V1.FallbackControllerTest do + use AprsmeWeb.ConnCase, async: true + + import Phoenix.Controller, only: [put_format: 2] + + alias AprsmeWeb.Api.V1.FallbackController + + defp invalid_changeset do + %Aprsme.Devices{} |> Aprsme.Devices.changeset(%{}) |> Map.put(:action, :insert) + end + + # Phoenix's render/3 looks up `_format` in conn.params. The bare build_conn + # hasn't fetched params — put_format/2 short-circuits that lookup so the + # fallback controller can be exercised in isolation. + defp prep(conn), do: put_format(conn, "json") + + defp json(conn), do: Jason.decode!(conn.resp_body) + + describe "call/2" do + test "handles {:error, changeset}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, invalid_changeset()}) + assert conn.status == 422 + body = json(conn) + assert body["error"]["code"] == "validation_error" + assert is_map(body["error"]["details"]) + end + + test "handles {:error, :not_found}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, :not_found}) + assert conn.status == 404 + assert json(conn)["error"]["code"] == "not_found" + end + + test "handles {:error, :unauthorized}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, :unauthorized}) + assert conn.status == 401 + assert json(conn)["error"]["code"] == "generic_error" + end + + test "handles {:error, :forbidden}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, :forbidden}) + assert conn.status == 403 + end + + test "handles {:error, :bad_request}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, :bad_request}) + assert conn.status == 400 + end + + test "unknown atom reasons produce 500", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, :totally_unknown}) + assert conn.status == 500 + end + + test "handles {:error, atom, message}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, :bad_request, "specific message"}) + assert conn.status == 400 + assert json(conn)["error"]["message"] == "specific message" + end + + test "handles {:error, binary}", %{conn: conn} do + conn = FallbackController.call(prep(conn), {:error, "custom error"}) + assert conn.status == 400 + assert json(conn)["error"]["message"] == "custom error" + end + + test "handles :timeout", %{conn: conn} do + conn = FallbackController.call(prep(conn), :timeout) + assert conn.status == 408 + end + + test "handles unknown errors with 500", %{conn: conn} do + conn = FallbackController.call(prep(conn), :some_weird_thing) + assert conn.status == 500 + assert json(conn)["error"]["code"] == "internal_server_error" + end + end +end diff --git a/test/aprsme_web/controllers/api/v1/json/callsign_json_test.exs b/test/aprsme_web/controllers/api/v1/json/callsign_json_test.exs new file mode 100644 index 0000000..18aa923 --- /dev/null +++ b/test/aprsme_web/controllers/api/v1/json/callsign_json_test.exs @@ -0,0 +1,208 @@ +defmodule AprsmeWeb.Api.V1.CallsignJSONTest do + use ExUnit.Case, async: true + + alias Aprsme.Packet + alias AprsmeWeb.Api.V1.CallsignJSON + + defp base_packet(attrs \\ %{}) do + defaults = %{ + id: 1, + base_callsign: "K5ABC", + ssid: "9", + sender: "K5ABC-9", + destination: "APN382", + path: "WIDE1-1", + data_type: "position", + data: %{"information_field" => "raw data"}, + raw_packet: "K5ABC-9>APN382,WIDE1-1:!3000.00N/09500.00W>test", + received_at: ~U[2024-01-15 12:00:00Z], + region: "US", + has_position: true, + lat: 30.5, + lon: -95.0, + course: 180, + speed: 55.0, + altitude: 100.0, + symbol_code: ">", + symbol_table_id: "/", + comment: "test", + timestamp: "t", + aprs_messaging: false, + temperature: nil, + humidity: nil, + wind_speed: nil, + wind_direction: nil, + wind_gust: nil, + pressure: nil, + rain_1h: nil, + rain_24h: nil, + rain_since_midnight: nil, + device_identifier: nil, + manufacturer: nil, + equipment_type: nil, + addressee: nil, + message_text: nil, + message_number: nil, + inserted_at: ~N[2024-01-15 12:00:00], + updated_at: ~N[2024-01-15 12:00:00] + } + + struct(Packet, Map.merge(defaults, attrs)) + end + + describe "render show.json" do + test "wraps the packet in a :data key" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert is_map(result.data) + assert result.data.id == 1 + end + + test "includes callsign formatted with ssid" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.callsign == "K5ABC-9" + end + + test "omits ssid from callsign when nil" do + result = CallsignJSON.render("show.json", %{packet: base_packet(%{ssid: nil})}) + assert result.data.callsign == "K5ABC" + end + + test "omits ssid '0' from callsign" do + result = CallsignJSON.render("show.json", %{packet: base_packet(%{ssid: "0"})}) + assert result.data.callsign == "K5ABC" + end + + test "extracts information_field from data map" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.information_field == "raw data" + end + + test "handles nil data map without crashing" do + result = CallsignJSON.render("show.json", %{packet: base_packet(%{data: nil})}) + assert result.data.information_field == nil + end + + test "includes position with altitude/course/speed when present" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.position.latitude == 30.5 + assert result.data.position.longitude == -95.0 + assert result.data.position.altitude == 100.0 + assert result.data.position.course == 180 + assert result.data.position.speed == 55.0 + end + + test "omits altitude/course/speed from position when nil" do + packet = base_packet(%{altitude: nil, course: nil, speed: nil}) + result = CallsignJSON.render("show.json", %{packet: packet}) + refute Map.has_key?(result.data.position, :altitude) + refute Map.has_key?(result.data.position, :course) + refute Map.has_key?(result.data.position, :speed) + end + + test "returns nil position when has_position is false" do + result = CallsignJSON.render("show.json", %{packet: base_packet(%{has_position: false})}) + assert result.data.position == nil + end + + test "returns nil position when lat and lon are nil" do + result = CallsignJSON.render("show.json", %{packet: base_packet(%{lat: nil, lon: nil})}) + assert result.data.position == nil + end + + test "converts Decimal coordinates to floats" do + packet = base_packet(%{lat: Decimal.new("30.5"), lon: Decimal.new("-95.0")}) + result = CallsignJSON.render("show.json", %{packet: packet}) + assert result.data.position.latitude == 30.5 + assert result.data.position.longitude == -95.0 + end + + test "returns nil symbol when both symbol fields missing" do + packet = base_packet(%{symbol_code: nil, symbol_table_id: nil}) + result = CallsignJSON.render("show.json", %{packet: packet}) + assert result.data.symbol == nil + end + + test "includes symbol when present" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.symbol == %{code: ">", table_id: "/"} + end + + test "returns nil weather when all weather fields empty" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.weather == nil + end + + test "includes only non-nil weather fields" do + packet = base_packet(%{temperature: 72.0, humidity: 50.0}) + result = CallsignJSON.render("show.json", %{packet: packet}) + assert result.data.weather == %{temperature: 72.0, humidity: 50.0} + end + + test "returns nil message when all message fields empty" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.message == nil + end + + test "includes only non-nil message fields" do + packet = + base_packet(%{ + addressee: "BLN", + message_text: "hello", + message_number: "001" + }) + + result = CallsignJSON.render("show.json", %{packet: packet}) + assert result.data.message == %{addressee: "BLN", text: "hello", number: "001"} + end + + test "excludes empty-string values via maybe_add" do + packet = base_packet(%{comment: ""}) + result = CallsignJSON.render("show.json", %{packet: packet}) + # maybe_add drops empty strings, but comment is a top-level field added + # unconditionally — so it's retained as-is at the top level. + assert result.data.comment == "" + end + end + + describe "render index.json" do + test "wraps a list of packets" do + packets = [base_packet(), base_packet(%{id: 2, sender: "K5ABC-10"})] + result = CallsignJSON.render("index.json", %{packets: packets}) + assert length(result.data) == 2 + assert Enum.map(result.data, & &1.id) == [1, 2] + end + + test "handles an empty list" do + assert CallsignJSON.render("index.json", %{packets: []}) == %{data: []} + end + end + + describe "render not_found.json" do + test "returns nil data and a descriptive message" do + result = CallsignJSON.render("not_found.json", %{callsign: "K5ABC"}) + assert result.data == nil + assert result.message == "No packets found for callsign K5ABC" + end + end + + describe "equipment rendering" do + test "returns nil equipment when no equipment data" do + result = CallsignJSON.render("show.json", %{packet: base_packet()}) + assert result.data.equipment == nil + end + + test "includes manufacturer/equipment_type when set (no device cache hit)" do + packet = + base_packet(%{ + manufacturer: "Yaesu", + equipment_type: "FT1D", + # "" avoids a DeviceCache.lookup_device/1 call (nil/"" short-circuits). + device_identifier: "" + }) + + result = CallsignJSON.render("show.json", %{packet: packet}) + assert result.data.equipment.manufacturer == "Yaesu" + assert result.data.equipment.equipment_type == "FT1D" + end + end +end diff --git a/test/aprsme_web/controllers/api/v1/json/changeset_json_test.exs b/test/aprsme_web/controllers/api/v1/json/changeset_json_test.exs new file mode 100644 index 0000000..ecb4201 --- /dev/null +++ b/test/aprsme_web/controllers/api/v1/json/changeset_json_test.exs @@ -0,0 +1,43 @@ +defmodule AprsmeWeb.Api.V1.ChangesetJSONTest do + use ExUnit.Case, async: true + + alias AprsmeWeb.Api.V1.ChangesetJSON + + defp invalid_changeset do + # Use Devices since it has a straightforward required field. + %Aprsme.Devices{} |> Aprsme.Devices.changeset(%{}) |> Map.put(:action, :insert) + end + + describe "render error.json" do + test "returns validation_error envelope with details" do + result = ChangesetJSON.render("error.json", %{changeset: invalid_changeset()}) + assert result.error.code == "validation_error" + assert is_binary(result.error.message) + assert is_map(result.error.details) + end + + test "details include the failing field" do + result = ChangesetJSON.render("error.json", %{changeset: invalid_changeset()}) + assert Map.has_key?(result.error.details, :identifier) + [msg | _] = result.error.details.identifier + assert is_binary(msg) + end + + test "interpolates values from error opts" do + # Build a changeset whose error uses interpolation (min: 3). + types = %{name: :string} + params = %{name: "a"} + + changeset = + {%{}, types} + |> Ecto.Changeset.cast(params, Map.keys(types)) + |> Ecto.Changeset.validate_length(:name, min: 3) + + result = ChangesetJSON.render("error.json", %{changeset: changeset}) + [msg | _] = result.error.details.name + # %{count} should be substituted with "3" + refute msg =~ "%{count}" + assert msg =~ "3" + end + end +end diff --git a/test/aprsme_web/controllers/api/v1/json/error_json_test.exs b/test/aprsme_web/controllers/api/v1/json/error_json_test.exs new file mode 100644 index 0000000..78b16bc --- /dev/null +++ b/test/aprsme_web/controllers/api/v1/json/error_json_test.exs @@ -0,0 +1,53 @@ +defmodule AprsmeWeb.Api.V1.ErrorJSONTest do + use ExUnit.Case, async: true + + alias AprsmeWeb.Api.V1.ErrorJSON + + describe "render/2 with explicit messages" do + test "renders a generic error with message and code" do + assert ErrorJSON.render("error.json", %{message: "boom"}) == %{ + error: %{message: "boom", code: "generic_error"} + } + end + end + + describe "render/2 for status-keyed templates" do + test "renders 404 with not_found code" do + result = ErrorJSON.render("404.json", %{}) + assert result.error.code == "not_found" + assert is_binary(result.error.message) + end + + test "renders 500 with internal_server_error code" do + assert ErrorJSON.render("500.json", %{}).error.code == "internal_server_error" + end + + test "renders 422 with unprocessable_entity code" do + assert ErrorJSON.render("422.json", %{}).error.code == "unprocessable_entity" + end + + test "renders 400 with bad_request code" do + assert ErrorJSON.render("400.json", %{}).error.code == "bad_request" + end + + test "renders 401 with unauthorized code" do + assert ErrorJSON.render("401.json", %{}).error.code == "unauthorized" + end + + test "renders 403 with forbidden code" do + assert ErrorJSON.render("403.json", %{}).error.code == "forbidden" + end + + test "renders 408 with request_timeout code" do + assert ErrorJSON.render("408.json", %{}).error.code == "request_timeout" + end + end + + describe "render/2 fallback" do + test "renders unknown templates with generic unknown_error code" do + result = ErrorJSON.render("418.json", %{}) + assert result.error.code == "unknown_error" + assert is_binary(result.error.message) + end + end +end diff --git a/test/aprsme_web/live/components/symbol_renderer_test.exs b/test/aprsme_web/live/components/symbol_renderer_test.exs new file mode 100644 index 0000000..ca80048 --- /dev/null +++ b/test/aprsme_web/live/components/symbol_renderer_test.exs @@ -0,0 +1,90 @@ +defmodule AprsmeWeb.SymbolRendererTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias AprsmeWeb.SymbolRenderer + + describe "get_sprite_info/2" do + test "delegates to AprsmeWeb.AprsSymbol" do + info = SymbolRenderer.get_sprite_info("/", "_") + assert is_map(info) + assert Map.has_key?(info, :sprite_file) + assert Map.has_key?(info, :background_position) + assert Map.has_key?(info, :background_size) + end + end + + describe "render_marker_symbol/4" do + test "returns HTML string for a symbol" do + html = SymbolRenderer.render_marker_symbol("/", ">", "K5ABC", 32) + assert is_binary(html) + assert html =~ "K5ABC" + end + + test "works without a callsign" do + html = SymbolRenderer.render_marker_symbol("/", ">") + assert is_binary(html) + end + end + + describe "symbol/1 component" do + test "renders a container with the given size" do + html = render_component(&SymbolRenderer.symbol/1, symbol_table: "/", symbol_code: ">", size: 48) + assert html =~ "aprs-symbol-container" + assert html =~ "width: 48px" + assert html =~ "height: 48px" + end + + test "includes default size (32px) when size not specified" do + html = render_component(&SymbolRenderer.symbol/1, symbol_table: "/", symbol_code: ">") + assert html =~ "width: 32px" + assert html =~ "height: 32px" + end + + test "renders a callsign label when provided" do + html = + render_component(&SymbolRenderer.symbol/1, + symbol_table: "/", + symbol_code: ">", + callsign: "W1AW" + ) + + assert html =~ "W1AW" + assert html =~ "aprs-callsign-label" + end + + test "does not render a callsign label when nil" do + html = render_component(&SymbolRenderer.symbol/1, symbol_table: "/", symbol_code: ">") + refute html =~ "aprs-callsign-label" + end + + test "uses explicit title when provided" do + html = + render_component(&SymbolRenderer.symbol/1, + symbol_table: "/", + symbol_code: ">", + title: "Custom title" + ) + + assert html =~ "Custom title" + end + + test "derives title from symbol_table and symbol_code when no explicit title" do + html = render_component(&SymbolRenderer.symbol/1, symbol_table: "/", symbol_code: ">") + # ">" is HTML-escaped to ">" + assert html =~ ~s(title="/>") + end + + test "passes additional class through" do + html = + render_component(&SymbolRenderer.symbol/1, + symbol_table: "/", + symbol_code: ">", + class: "extra-class" + ) + + assert html =~ "extra-class" + end + end +end diff --git a/test/aprsme_web/live/map_live/map_helpers_test.exs b/test/aprsme_web/live/map_live/map_helpers_test.exs deleted file mode 100644 index 9106b2e..0000000 --- a/test/aprsme_web/live/map_live/map_helpers_test.exs +++ /dev/null @@ -1,128 +0,0 @@ -defmodule AprsmeWeb.Live.Shared.CoordinateUtilsTest do - use ExUnit.Case, async: true - - alias Aprs.Types.MicE - alias AprsmeWeb.Live.Shared.BoundsUtils - alias AprsmeWeb.Live.Shared.CoordinateUtils - - describe "get_coordinates/1" do - test "returns lat/lon/data_extended for map with lat/lon" do - packet = %{lat: 10.0, lon: 20.0, data_extended: %{foo: :bar}} - assert CoordinateUtils.get_coordinates(packet) == {10.0, 20.0, %{foo: :bar}} - end - - test "returns lat/lon/data_extended for map with latitude/longitude in data_extended" do - packet = %{data_extended: %{latitude: 11.1, longitude: 22.2}} - - assert CoordinateUtils.get_coordinates(packet) == - {11.1, 22.2, %{latitude: 11.1, longitude: 22.2}} - end - - test "returns lat/lon/mic_e for MicE struct" do - mic_e = %MicE{ - lat_degrees: 12, - lat_minutes: 34, - lat_fractional: 0, - lat_direction: :north, - lon_degrees: 56, - lon_minutes: 78, - lon_fractional: 0, - lon_direction: :east - } - - packet = %{data_extended: mic_e} - {lat, lon, ext} = CoordinateUtils.get_coordinates(packet) - assert is_number(lat) and is_number(lon) - assert ext == mic_e - end - - test "returns {nil, nil, nil} for missing data" do - assert CoordinateUtils.get_coordinates(%{}) == {nil, nil, nil} - end - end - - describe "get_coordinates_from_mic_e/1" do - test "returns correct lat/lon for valid MicE" do - mic_e = %MicE{ - lat_degrees: 10, - lat_minutes: 30, - lat_fractional: 0, - lat_direction: :north, - lon_degrees: 20, - lon_minutes: 40, - lon_fractional: 0, - lon_direction: :east - } - - {lat, lon} = CoordinateUtils.get_coordinates_from_mic_e(mic_e) - assert_in_delta lat, 10.5, 0.0001 - assert_in_delta lon, 20.6667, 0.0001 - end - - test "returns {nil, nil} for out-of-bounds" do - mic_e = %MicE{ - lat_degrees: 100, - lat_minutes: 0, - lat_fractional: 0, - lat_direction: :north, - lon_degrees: 200, - lon_minutes: 0, - lon_fractional: 0, - lon_direction: :east - } - - assert CoordinateUtils.get_coordinates_from_mic_e(mic_e) == {nil, nil} - end - end - - describe "has_position_data?/1" do - test "true for MicE in data_extended" do - mic_e = %MicE{ - lat_degrees: 1, - lat_minutes: 1, - lat_fractional: 0, - lat_direction: :north, - lon_degrees: 1, - lon_minutes: 1, - lon_fractional: 0, - lon_direction: :east - } - - assert CoordinateUtils.has_position_data?(%{data_extended: mic_e}) - end - - test "true for latitude/longitude in data_extended" do - assert CoordinateUtils.has_position_data?(%{data_extended: %{latitude: 1, longitude: 2}}) - end - - test "true for lat/lon at top level" do - assert CoordinateUtils.has_position_data?(%{lat: 1, lon: 2}) - end - - test "false for missing position" do - refute CoordinateUtils.has_position_data?(%{}) - end - end - - describe "within_bounds?/2" do - test "true for point in bounds" do - bounds = %{north: 10, south: 0, east: 10, west: 0} - assert BoundsUtils.within_bounds?(%{lat: 5, lon: 5}, bounds) - end - - test "false for point out of bounds" do - bounds = %{north: 10, south: 0, east: 10, west: 0} - refute BoundsUtils.within_bounds?(%{lat: 15, lon: 5}, bounds) - end - - test "true for tuple input" do - bounds = %{north: 10, south: 0, east: 10, west: 0} - assert BoundsUtils.within_bounds?({5, 5}, bounds) - end - - test "false for nil input" do - bounds = %{north: 10, south: 0, east: 10, west: 0} - refute BoundsUtils.within_bounds?(%{}, bounds) - end - end -end diff --git a/test/aprsme_web/live/map_live/packet_utils_test.exs b/test/aprsme_web/live/map_live/packet_utils_test.exs index 08149a7..098e922 100644 --- a/test/aprsme_web/live/map_live/packet_utils_test.exs +++ b/test/aprsme_web/live/map_live/packet_utils_test.exs @@ -377,6 +377,39 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do end end + describe "convert_tuples_to_strings/1" do + test "converts a bare tuple to an inspect string" do + assert PacketUtils.convert_tuples_to_strings({1, 2}) == "{1, 2}" + end + + test "converts tuple values inside maps" do + result = PacketUtils.convert_tuples_to_strings(%{a: {1, 2}, b: "keep"}) + assert result.a == "{1, 2}" + assert result.b == "keep" + end + + test "walks nested maps" do + result = PacketUtils.convert_tuples_to_strings(%{outer: %{inner: {:ok, "v"}}}) + assert result.outer.inner =~ "{:ok," + end + + test "walks lists" do + assert PacketUtils.convert_tuples_to_strings([{1, 2}, {3, 4}]) == ["{1, 2}", "{3, 4}"] + end + + test "leaves structs alone" do + dt = ~U[2024-01-15 12:00:00Z] + assert PacketUtils.convert_tuples_to_strings(dt) == dt + end + + test "passes through scalars" do + assert PacketUtils.convert_tuples_to_strings(42) == 42 + assert PacketUtils.convert_tuples_to_strings("hello") == "hello" + assert PacketUtils.convert_tuples_to_strings(nil) == nil + assert PacketUtils.convert_tuples_to_strings(:atom) == :atom + end + end + describe "callsign generation" do test "generates callsign with SSID" do packet = %{ diff --git a/test/aprsme_web/live/map_live/url_params_test.exs b/test/aprsme_web/live/map_live/url_params_test.exs new file mode 100644 index 0000000..bf91278 --- /dev/null +++ b/test/aprsme_web/live/map_live/url_params_test.exs @@ -0,0 +1,95 @@ +defmodule AprsmeWeb.MapLive.UrlParamsTest do + use ExUnit.Case, async: true + + alias AprsmeWeb.MapLive.UrlParams + + describe "parse_map_params/1" do + test "parses a full set of params" do + {center, zoom} = UrlParams.parse_map_params(%{"lat" => "40.0", "lng" => "-95.0", "z" => "7"}) + assert center == %{lat: 40.0, lng: -95.0} + assert zoom == 7 + end + + test "falls back to defaults for missing params" do + {center, zoom} = UrlParams.parse_map_params(%{}) + assert center == UrlParams.default_center() + assert zoom == UrlParams.default_zoom() + end + + test "falls back to defaults for garbage values" do + {center, zoom} = UrlParams.parse_map_params(%{"lat" => "foo", "lng" => "bar", "z" => "baz"}) + assert center == UrlParams.default_center() + assert zoom == UrlParams.default_zoom() + end + + test "rejects out-of-range values" do + {center, _} = UrlParams.parse_map_params(%{"lat" => "200.0", "lng" => "400.0"}) + assert center == UrlParams.default_center() + end + end + + describe "parse_latitude/1 / parse_longitude/1 / parse_zoom/1" do + test "individual parsers accept valid values" do + assert UrlParams.parse_latitude("30.5") == 30.5 + assert UrlParams.parse_longitude("-120.0") == -120.0 + assert UrlParams.parse_zoom("10") == 10 + end + + test "individual parsers return defaults for nil" do + assert UrlParams.parse_latitude(nil) == UrlParams.default_center().lat + assert UrlParams.parse_longitude(nil) == UrlParams.default_center().lng + assert UrlParams.parse_zoom(nil) == UrlParams.default_zoom() + end + + test "individual parsers clamp invalid strings to defaults" do + assert UrlParams.parse_latitude("garbage") == UrlParams.default_center().lat + assert UrlParams.parse_zoom("garbage") == UrlParams.default_zoom() + end + end + + describe "has_explicit_url_params?/1" do + test "returns true when any of lat, lng, or z is present" do + assert UrlParams.has_explicit_url_params?(%{"lat" => "1"}) + assert UrlParams.has_explicit_url_params?(%{"lng" => "1"}) + assert UrlParams.has_explicit_url_params?(%{"z" => "5"}) + end + + test "returns false when none of lat, lng, z is present" do + refute UrlParams.has_explicit_url_params?(%{}) + refute UrlParams.has_explicit_url_params?(%{"other" => "1"}) + end + + test "returns false when keys are present but empty-string (treated truthy)" do + # Empty string is truthy in Elixir, so this actually returns true. + assert UrlParams.has_explicit_url_params?(%{"lat" => ""}) + end + end + + describe "defaults" do + test "default_center returns USA-centered coordinates" do + %{lat: lat, lng: lng} = UrlParams.default_center() + assert is_float(lat) and is_float(lng) + assert lat >= 25 and lat <= 50 + assert lng >= -125 and lng <= -66 + end + + test "default_zoom is a valid zoom" do + assert UrlParams.default_zoom() == 5 + end + end + + describe "delegated helpers still work" do + test "parse_float_in_range delegates to ParamUtils" do + assert UrlParams.parse_float_in_range("30.5", 0.0, -90.0, 90.0) == 30.5 + end + + test "sanitize_numeric_string delegates to ParamUtils" do + assert UrlParams.sanitize_numeric_string("12") == "12" + end + + test "valid_coordinates? delegates to CoordinateUtils" do + assert UrlParams.valid_coordinates?(30.5, -95.0) + refute UrlParams.valid_coordinates?(200, 0) + end + end +end diff --git a/test/aprsme_web/live/shared/bounds_utils_test.exs b/test/aprsme_web/live/shared/bounds_utils_test.exs new file mode 100644 index 0000000..1850a8b --- /dev/null +++ b/test/aprsme_web/live/shared/bounds_utils_test.exs @@ -0,0 +1,237 @@ +defmodule AprsmeWeb.Live.Shared.BoundsUtilsTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias AprsmeWeb.Live.Shared.BoundsUtils + + defp world_bounds, do: %{north: 85.0, south: -85.0, east: 180.0, west: -180.0} + defp usa_bounds, do: %{north: 49.0, south: 25.0, east: -67.0, west: -125.0} + # crosses antimeridian + defp pacific_bounds, do: %{north: 60.0, south: -60.0, east: -170.0, west: 170.0} + + describe "valid_bounds?/1" do + test "accepts valid bounds" do + assert BoundsUtils.valid_bounds?(world_bounds()) + assert BoundsUtils.valid_bounds?(usa_bounds()) + end + + test "rejects bounds with north <= south" do + refute BoundsUtils.valid_bounds?(%{north: 10.0, south: 20.0, east: 0.0, west: -10.0}) + refute BoundsUtils.valid_bounds?(%{north: 10.0, south: 10.0, east: 0.0, west: -10.0}) + end + + test "rejects bounds with out-of-range latitude" do + refute BoundsUtils.valid_bounds?(%{north: 91.0, south: 0.0, east: 10.0, west: -10.0}) + refute BoundsUtils.valid_bounds?(%{north: 10.0, south: -91.0, east: 10.0, west: -10.0}) + end + + test "rejects non-map input" do + refute BoundsUtils.valid_bounds?(nil) + refute BoundsUtils.valid_bounds?(%{}) + refute BoundsUtils.valid_bounds?("bounds") + end + + test "rejects bounds with non-numeric values" do + refute BoundsUtils.valid_bounds?(%{north: "10", south: 0.0, east: 0.0, west: 0.0}) + end + end + + describe "compare_bounds/2" do + test "two nils are equal" do + assert BoundsUtils.compare_bounds(nil, nil) + end + + test "nil vs map is not equal" do + refute BoundsUtils.compare_bounds(nil, usa_bounds()) + refute BoundsUtils.compare_bounds(usa_bounds(), nil) + end + + test "identical bounds compare equal" do + assert BoundsUtils.compare_bounds(usa_bounds(), usa_bounds()) + end + + test "bounds equal within rounding to 4 places" do + a = %{north: 10.00001, south: 0.0, east: 0.0, west: -10.0} + b = %{north: 10.00002, south: 0.0, east: 0.0, west: -10.0} + assert BoundsUtils.compare_bounds(a, b) + end + + test "bounds different beyond 4 places compare unequal" do + a = %{north: 10.0001, south: 0.0, east: 0.0, west: -10.0} + b = %{north: 10.0003, south: 0.0, east: 0.0, west: -10.0} + refute BoundsUtils.compare_bounds(a, b) + end + + test "integer vs equivalent float compares equal" do + a = %{north: 10, south: 0, east: 5, west: -5} + b = %{north: 10.0, south: 0.0, east: 5.0, west: -5.0} + assert BoundsUtils.compare_bounds(a, b) + end + end + + describe "within_bounds?/2" do + test "coordinates inside USA bounds match" do + assert BoundsUtils.within_bounds?({40.0, -100.0}, usa_bounds()) + assert BoundsUtils.within_bounds?(%{lat: 40.0, lon: -100.0}, usa_bounds()) + end + + test "coordinates outside USA bounds don't match" do + refute BoundsUtils.within_bounds?({0.0, 0.0}, usa_bounds()) + refute BoundsUtils.within_bounds?(%{lat: 60.0, lon: -100.0}, usa_bounds()) + end + + test "coordinates on antimeridian-crossing bounds" do + # Pacific bounds go from west=170 to east=-170, wrapping across 180. + assert BoundsUtils.within_bounds?({0.0, 175.0}, pacific_bounds()) + assert BoundsUtils.within_bounds?({0.0, -175.0}, pacific_bounds()) + refute BoundsUtils.within_bounds?({0.0, 0.0}, pacific_bounds()) + end + + test "nil coordinates are never within bounds" do + refute BoundsUtils.within_bounds?(%{lat: nil, lon: nil}, usa_bounds()) + refute BoundsUtils.within_bounds?(%{}, usa_bounds()) + end + end + + describe "filter_packets_by_bounds/2" do + test "filters a list of packets" do + packets = [ + %{lat: 40.0, lon: -100.0, id: "in"}, + %{lat: 0.0, lon: 0.0, id: "out"} + ] + + result = BoundsUtils.filter_packets_by_bounds(packets, usa_bounds()) + assert length(result) == 1 + assert hd(result).id == "in" + end + + test "filters a map of packets" do + packets = %{ + "a" => %{lat: 40.0, lon: -100.0}, + "b" => %{lat: 0.0, lon: 0.0} + } + + result = BoundsUtils.filter_packets_by_bounds(packets, usa_bounds()) + assert Map.has_key?(result, "a") + refute Map.has_key?(result, "b") + end + + test "returns empty on empty input" do + assert BoundsUtils.filter_packets_by_bounds([], usa_bounds()) == [] + assert BoundsUtils.filter_packets_by_bounds(%{}, usa_bounds()) == %{} + end + end + + describe "reject_packets_by_bounds/2" do + test "returns keys for packets outside bounds" do + packets = %{ + "in" => %{lat: 40.0, lon: -100.0}, + "out" => %{lat: 0.0, lon: 0.0} + } + + assert BoundsUtils.reject_packets_by_bounds(packets, usa_bounds()) == ["out"] + end + + test "returns empty list when everything is in bounds" do + packets = %{"a" => %{lat: 40.0, lon: -100.0}} + assert BoundsUtils.reject_packets_by_bounds(packets, usa_bounds()) == [] + end + end + + describe "normalize_bounds/1" do + test "converts string keys to atoms" do + result = + BoundsUtils.normalize_bounds(%{ + "north" => 49.0, + "south" => 25.0, + "east" => -67.0, + "west" => -125.0 + }) + + assert result == usa_bounds() + end + + test "converts string values to floats" do + result = + BoundsUtils.normalize_bounds(%{ + "north" => "49.0", + "south" => "25.0", + "east" => "-67.0", + "west" => "-125.0" + }) + + assert result.north == 49.0 + assert result.south == 25.0 + end + + test "passes through already-atom bounds" do + assert BoundsUtils.normalize_bounds(usa_bounds()) == usa_bounds() + end + + test "returns nil for unsupported input" do + assert BoundsUtils.normalize_bounds(nil) == nil + assert BoundsUtils.normalize_bounds(%{foo: "bar"}) == nil + assert BoundsUtils.normalize_bounds("bounds") == nil + end + end + + describe "calculate_bounds_from_center_and_zoom/2" do + test "centered bounds contain the center" do + center = %{lat: 40.0, lng: -100.0} + bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 5) + assert bounds.south <= center.lat and bounds.north >= center.lat + assert bounds.west <= center.lng and bounds.east >= center.lng + end + + test "higher zoom produces tighter bounds" do + center = %{lat: 40.0, lng: -100.0} + b_low = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 3) + b_high = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 15) + low_width = b_low.east - b_low.west + high_width = b_high.east - b_high.west + assert high_width < low_width + end + + test "center-symmetric about the origin produces symmetric bounds" do + bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(%{lat: 0.0, lng: 0.0}, 5) + assert_in_delta bounds.north, -bounds.south, 0.0001 + assert_in_delta bounds.east, -bounds.west, 0.0001 + end + end + + describe "property: filter + reject partition the input" do + property "every packet key ends up in exactly one of filter/reject" do + packet_gen = + fixed_map(%{ + lat: float(min: -89.0, max: 89.0), + lon: float(min: -179.0, max: 179.0) + }) + + check all(packets <- map_of(string(:alphanumeric, min_length: 1, max_length: 5), packet_gen)) do + bounds = usa_bounds() + kept = packets |> BoundsUtils.filter_packets_by_bounds(bounds) |> Map.keys() + dropped = BoundsUtils.reject_packets_by_bounds(packets, bounds) + + assert Enum.sort(kept ++ dropped) == packets |> Map.keys() |> Enum.sort() + assert kept -- dropped == kept + end + end + end + + describe "property: within_bounds? matches manual range check (non-wrapping bounds)" do + property "matches manual check for any coordinate against USA bounds" do + usa = usa_bounds() + + check all( + lat <- float(min: -90.0, max: 90.0), + lon <- float(min: -180.0, max: 180.0) + ) do + expected = + lat >= usa.south and lat <= usa.north and + lon >= usa.west and lon <= usa.east + + assert BoundsUtils.within_bounds?({lat, lon}, usa) == expected + end + end + end +end diff --git a/test/aprsme_web/live/shared/coordinate_utils_test.exs b/test/aprsme_web/live/shared/coordinate_utils_test.exs new file mode 100644 index 0000000..a9584a6 --- /dev/null +++ b/test/aprsme_web/live/shared/coordinate_utils_test.exs @@ -0,0 +1,304 @@ +defmodule AprsmeWeb.Live.Shared.CoordinateUtilsTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Aprs.Types.MicE + alias AprsmeWeb.Live.Shared.CoordinateUtils + + describe "get_coordinates/1" do + test "extracts lat/lon from atom-keyed packet" do + packet = %{lat: 30.5, lon: -95.0} + assert CoordinateUtils.get_coordinates(packet) == {30.5, -95.0, nil} + end + + test "extracts lat/lon from string-keyed packet" do + packet = %{"lat" => 30.5, "lon" => -95.0} + assert CoordinateUtils.get_coordinates(packet) == {30.5, -95.0, nil} + end + + test "returns nil coordinates when missing" do + assert CoordinateUtils.get_coordinates(%{}) == {nil, nil, nil} + end + + test "extracts coordinates from data_extended.latitude/longitude" do + packet = %{data_extended: %{latitude: 10.0, longitude: 20.0, extra: :keep}} + {lat, lon, ext} = CoordinateUtils.get_coordinates(packet) + assert lat == 10.0 + assert lon == 20.0 + assert ext.extra == :keep + end + + test "extracts coordinates from a MicE data_extended" do + mic_e = %MicE{ + lat_degrees: 30, + lat_minutes: 30, + lat_fractional: 0, + lat_direction: :north, + lon_degrees: 95, + lon_minutes: 0, + lon_fractional: 0, + lon_direction: :west + } + + {lat, lon, ext} = CoordinateUtils.get_coordinates(%{data_extended: mic_e}) + assert_in_delta lat, 30.5, 0.001 + assert_in_delta lon, -95.0, 0.001 + assert ext == mic_e + end + + test "returns data_extended map when no latitude/longitude keys" do + packet = %{lat: 1.0, lon: 2.0, data_extended: %{other: :value}} + {lat, lon, ext} = CoordinateUtils.get_coordinates(packet) + assert lat == 1.0 + assert lon == 2.0 + assert ext == %{other: :value} + end + end + + describe "get_coordinates_from_mic_e/1" do + test "combines degrees, minutes, and fractional minutes" do + # lat = deg + min/60 + frac/6000 = 30 + 30/60 + 3000/6000 = 31.0 + mic_e = %MicE{ + lat_degrees: 30, + lat_minutes: 30, + lat_fractional: 3000, + lat_direction: :north, + lon_degrees: 95, + lon_minutes: 15, + lon_fractional: 0, + lon_direction: :east + } + + {lat, lon} = CoordinateUtils.get_coordinates_from_mic_e(mic_e) + assert_in_delta lat, 31.0, 0.0001 + assert_in_delta lon, 95.25, 0.0001 + end + + test "negates for southern/western hemispheres" do + mic_e = %MicE{ + lat_degrees: 30, + lat_minutes: 0, + lat_fractional: 0, + lat_direction: :south, + lon_degrees: 95, + lon_minutes: 0, + lon_fractional: 0, + lon_direction: :west + } + + {lat, lon} = CoordinateUtils.get_coordinates_from_mic_e(mic_e) + assert lat == -30.0 + assert lon == -95.0 + end + + test "returns {nil, nil} for out-of-range coordinates" do + mic_e = %MicE{ + lat_degrees: 200, + lat_minutes: 0, + lat_fractional: 0, + lat_direction: :north, + lon_degrees: 0, + lon_minutes: 0, + lon_fractional: 0, + lon_direction: :east + } + + assert CoordinateUtils.get_coordinates_from_mic_e(mic_e) == {nil, nil} + end + end + + describe "has_position_data?/1" do + test "returns true for direct coordinates" do + assert CoordinateUtils.has_position_data?(%{lat: 1.0, lon: 2.0}) + assert CoordinateUtils.has_position_data?(%{"lat" => 1.0, "lon" => 2.0}) + end + + test "returns true when data_extended has latitude and longitude" do + assert CoordinateUtils.has_position_data?(%{data_extended: %{latitude: 1.0, longitude: 2.0}}) + end + + test "returns true for MicE data_extended" do + assert CoordinateUtils.has_position_data?(%{data_extended: %MicE{}}) + end + + test "returns false for empty maps" do + refute CoordinateUtils.has_position_data?(%{}) + end + + test "returns false when coordinates are nil" do + refute CoordinateUtils.has_position_data?(%{lat: nil, lon: nil}) + end + + test "returns false when data_extended has nil lat/lon" do + refute CoordinateUtils.has_position_data?(%{data_extended: %{latitude: nil, longitude: nil}}) + end + end + + describe "valid_coordinates?/2" do + test "accepts valid integer and float coordinates" do + assert CoordinateUtils.valid_coordinates?(0, 0) + assert CoordinateUtils.valid_coordinates?(30.5, -95.0) + assert CoordinateUtils.valid_coordinates?(90, 180) + assert CoordinateUtils.valid_coordinates?(-90, -180) + end + + test "rejects out-of-range values" do + refute CoordinateUtils.valid_coordinates?(91, 0) + refute CoordinateUtils.valid_coordinates?(-91, 0) + refute CoordinateUtils.valid_coordinates?(0, 181) + refute CoordinateUtils.valid_coordinates?(0, -181) + end + + test "rejects non-numbers" do + refute CoordinateUtils.valid_coordinates?(nil, 0) + refute CoordinateUtils.valid_coordinates?(0, nil) + refute CoordinateUtils.valid_coordinates?("30", "-95") + end + end + + describe "valid_coordinates_any_type?/2" do + test "accepts Decimal coordinates" do + assert CoordinateUtils.valid_coordinates_any_type?(Decimal.new("30.5"), Decimal.new("-95.0")) + end + + test "accepts mixed numeric types" do + assert CoordinateUtils.valid_coordinates_any_type?(30, -95.0) + end + + test "rejects Decimal out of range" do + refute CoordinateUtils.valid_coordinates_any_type?(Decimal.new("200"), Decimal.new("0")) + end + + test "rejects non-numeric inputs" do + refute CoordinateUtils.valid_coordinates_any_type?(nil, nil) + refute CoordinateUtils.valid_coordinates_any_type?("abc", "xyz") + end + end + + describe "normalize_coordinate/1" do + test "converts Decimal to float" do + assert CoordinateUtils.normalize_coordinate(Decimal.new("30.5")) == 30.5 + end + + test "passes through non-Decimal" do + assert CoordinateUtils.normalize_coordinate(42) == 42 + assert CoordinateUtils.normalize_coordinate(30.5) == 30.5 + assert CoordinateUtils.normalize_coordinate(nil) == nil + end + end + + describe "normalize_longitude/1" do + test "leaves in-range longitude alone" do + assert CoordinateUtils.normalize_longitude(0) == 0 + assert CoordinateUtils.normalize_longitude(100.5) == 100.5 + assert CoordinateUtils.normalize_longitude(-170) == -170 + end + + test "wraps positive longitude > 180" do + assert CoordinateUtils.normalize_longitude(190) == -170 + end + + test "wraps negative longitude < -180" do + assert CoordinateUtils.normalize_longitude(-190) == 170 + end + + test "handles multiple wraps" do + assert CoordinateUtils.normalize_longitude(540) == 180 + assert CoordinateUtils.normalize_longitude(-540) == -180 + end + + test "passes through non-numbers" do + assert CoordinateUtils.normalize_longitude(nil) == nil + assert CoordinateUtils.normalize_longitude("170") == "170" + end + end + + describe "normalize_latitude/1" do + test "leaves in-range latitude alone" do + assert CoordinateUtils.normalize_latitude(0) == 0 + assert CoordinateUtils.normalize_latitude(45.5) == 45.5 + end + + test "clamps above 90" do + assert CoordinateUtils.normalize_latitude(91) == 90.0 + assert CoordinateUtils.normalize_latitude(100.0) == 90.0 + end + + test "clamps below -90" do + assert CoordinateUtils.normalize_latitude(-91) == -90.0 + end + + test "passes through non-numbers" do + assert CoordinateUtils.normalize_latitude(nil) == nil + end + end + + describe "calculate_distance_meters/4" do + test "returns 0 for identical points" do + assert CoordinateUtils.calculate_distance_meters(0.0, 0.0, 0.0, 0.0) == 0.0 + end + + test "computes symmetric distances" do + d1 = CoordinateUtils.calculate_distance_meters(0.0, 0.0, 10.0, 10.0) + d2 = CoordinateUtils.calculate_distance_meters(10.0, 10.0, 0.0, 0.0) + assert_in_delta d1, d2, 0.01 + end + + test "matches a known distance (NYC to LA, ~3936 km)" do + d = CoordinateUtils.calculate_distance_meters(40.7128, -74.0060, 34.0522, -118.2437) + # Accept a fairly loose tolerance — haversine vs the "true" number. + assert_in_delta d / 1000, 3936, 30 + end + + test "gives positive distance for distinct points" do + d = CoordinateUtils.calculate_distance_meters(0.0, 0.0, 0.01, 0.01) + assert d > 0 + end + end + + describe "property: normalize_longitude wraps into [-180, 180]" do + property "result is always within [-180, 180]" do + check all(lon <- float(min: -1080.0, max: 1080.0)) do + result = CoordinateUtils.normalize_longitude(lon) + assert result >= -180 and result <= 180 + end + end + end + + describe "property: normalize_latitude clamps into [-90, 90]" do + property "result is always within [-90, 90]" do + check all(lat <- float(min: -500.0, max: 500.0)) do + result = CoordinateUtils.normalize_latitude(lat) + assert result >= -90 and result <= 90 + end + end + end + + describe "property: valid_coordinates? agrees with range checks" do + property "result equals in-range predicate for numbers" do + check all( + lat <- float(min: -200.0, max: 200.0), + lon <- float(min: -400.0, max: 400.0) + ) do + expected = lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180 + assert CoordinateUtils.valid_coordinates?(lat, lon) == expected + end + end + end + + describe "property: distance is symmetric and non-negative" do + property "calculate_distance_meters is symmetric" do + check all( + lat1 <- float(min: -89.0, max: 89.0), + lon1 <- float(min: -179.0, max: 179.0), + lat2 <- float(min: -89.0, max: 89.0), + lon2 <- float(min: -179.0, max: 179.0) + ) do + d1 = CoordinateUtils.calculate_distance_meters(lat1, lon1, lat2, lon2) + d2 = CoordinateUtils.calculate_distance_meters(lat2, lon2, lat1, lon1) + assert d1 >= 0 + assert_in_delta d1, d2, 0.01 + end + end + end +end diff --git a/test/aprsme_web/live/shared/packet_utils_test.exs b/test/aprsme_web/live/shared/packet_utils_test.exs new file mode 100644 index 0000000..17f323f --- /dev/null +++ b/test/aprsme_web/live/shared/packet_utils_test.exs @@ -0,0 +1,339 @@ +defmodule AprsmeWeb.Live.Shared.PacketUtilsTest do + use ExUnit.Case, async: true + + alias AprsmeWeb.Live.Shared.PacketUtils + + describe "get_callsign_key/1" do + test "returns sender callsign when no object/item name" do + assert PacketUtils.get_callsign_key(%{sender: "K5ABC"}) == "K5ABC" + end + + test "prefers object_name when present" do + packet = %{sender: "K5ABC", object_name: "REPEATER1", data_type: "object"} + assert PacketUtils.get_callsign_key(packet) == "REPEATER1" + end + + test "prefers item_name when present" do + packet = %{sender: "K5ABC", item_name: "EVENT", data_type: "item"} + assert PacketUtils.get_callsign_key(packet) == "EVENT" + end + + test "object_name wins over item_name" do + packet = %{object_name: "OBJ", item_name: "ITEM", sender: "K5ABC"} + assert PacketUtils.get_callsign_key(packet) == "OBJ" + end + + test "returns a unique fallback key for non-maps" do + assert PacketUtils.get_callsign_key(nil) =~ ~r/^\d+$/ + end + + test "returns a unique fallback key when every field is empty" do + packet = %{sender: "", object_name: "", item_name: ""} + assert PacketUtils.get_callsign_key(packet) =~ ~r/^\d+$/ + end + + test "strips whitespace from object/item names" do + assert PacketUtils.get_callsign_key(%{object_name: " REPEATER "}) == "REPEATER" + end + end + + describe "prune_oldest_packets/2" do + test "returns the same map when size is within limit" do + packets = %{"a" => %{}, "b" => %{}} + assert PacketUtils.prune_oldest_packets(packets, 10) == packets + end + + test "keeps the newest packets by received_at DateTime" do + now = DateTime.utc_now() + older = DateTime.add(now, -3600, :second) + oldest = DateTime.add(now, -7200, :second) + + packets = %{ + "a" => %{received_at: now}, + "b" => %{received_at: older}, + "c" => %{received_at: oldest} + } + + result = PacketUtils.prune_oldest_packets(packets, 2) + assert map_size(result) == 2 + assert Map.has_key?(result, "a") + assert Map.has_key?(result, "b") + refute Map.has_key?(result, "c") + end + + test "supports integer timestamps via string-keyed received_at" do + packets = %{ + "a" => %{"received_at" => 1000}, + "b" => %{"received_at" => 500} + } + + result = PacketUtils.prune_oldest_packets(packets, 1) + assert Map.keys(result) == ["a"] + end + + test "treats packets without received_at as oldest (timestamp 0)" do + now = DateTime.utc_now() + packets = %{"a" => %{received_at: now}, "b" => %{no_date: true}} + result = PacketUtils.prune_oldest_packets(packets, 1) + assert Map.has_key?(result, "a") + end + + test "prunes to zero when max_count is 0 and size exceeds it" do + packets = %{"a" => %{received_at: DateTime.utc_now()}} + assert PacketUtils.prune_oldest_packets(packets, 0) == %{} + end + end + + describe "parse_trail_duration/1" do + test "accepts known values" do + for hours <- [1, 6, 12, 24, 48, 168] do + assert PacketUtils.parse_trail_duration(to_string(hours)) == hours + end + end + + test "rejects arbitrary numbers not in allowlist" do + assert PacketUtils.parse_trail_duration("3") == 1 + assert PacketUtils.parse_trail_duration("100") == 1 + assert PacketUtils.parse_trail_duration("-1") == 1 + end + + test "returns default for garbage and non-string input" do + assert PacketUtils.parse_trail_duration("abc") == 1 + assert PacketUtils.parse_trail_duration("24abc") == 1 + assert PacketUtils.parse_trail_duration(nil) == 1 + assert PacketUtils.parse_trail_duration(24) == 1 + end + end + + describe "parse_historical_hours/1" do + test "accepts known values" do + for hours <- [1, 3, 6, 12, 24] do + assert PacketUtils.parse_historical_hours(to_string(hours)) == hours + end + end + + test "rejects values not in allowlist" do + assert PacketUtils.parse_historical_hours("48") == 1 + assert PacketUtils.parse_historical_hours("0") == 1 + end + + test "returns default for garbage" do + assert PacketUtils.parse_historical_hours(nil) == 1 + assert PacketUtils.parse_historical_hours("abc") == 1 + end + end + + describe "get_packet_field/3" do + test "retrieves atom key" do + assert PacketUtils.get_packet_field(%{sender: "K5ABC"}, :sender) == "K5ABC" + end + + test "falls back to string key" do + assert PacketUtils.get_packet_field(%{"sender" => "K5ABC"}, :sender) == "K5ABC" + end + + test "falls back to data_extended atom key" do + assert PacketUtils.get_packet_field(%{data_extended: %{temperature: 72}}, :temperature) == 72 + end + + test "falls back to data_extended string key" do + assert PacketUtils.get_packet_field(%{"data_extended" => %{"humidity" => 50}}, :humidity) == 50 + end + + test "returns default when no match" do + assert PacketUtils.get_packet_field(%{}, :whatever, :fallback) == :fallback + end + + test "defaults to nil when no default given" do + assert PacketUtils.get_packet_field(%{}, :whatever) == nil + end + + test "handles nil data_extended gracefully" do + assert PacketUtils.get_packet_field(%{data_extended: nil}, :temperature, 0) == 0 + end + end + + describe "get_weather_field/2" do + test "reads from top-level atom key" do + assert PacketUtils.get_weather_field(%{temperature: 72}, :temperature) == 72 + end + + test "reads from data_extended" do + packet = %{data_extended: %{pressure: 1013}} + assert PacketUtils.get_weather_field(packet, :pressure) == 1013 + end + + test "returns nil when absent" do + assert PacketUtils.get_weather_field(%{}, :temperature) == nil + end + end + + describe "has_weather_data?/1" do + test "detects weather data in top-level fields" do + assert PacketUtils.has_weather_data?(%{temperature: 72}) + assert PacketUtils.has_weather_data?(%{humidity: 50}) + end + + test "detects weather data in data_extended" do + assert PacketUtils.has_weather_data?(%{data_extended: %{temperature: 72}}) + end + + test "returns false when no weather fields are present" do + refute PacketUtils.has_weather_data?(%{sender: "K5ABC"}) + refute PacketUtils.has_weather_data?(%{}) + end + end + + describe "weather_packet?/1" do + test "mirrors has_weather_data?/1" do + assert PacketUtils.weather_packet?(%{temperature: 72}) + refute PacketUtils.weather_packet?(%{sender: "K5ABC"}) + end + end + + describe "packet_within_time_threshold?/2" do + test "returns true when packet is after threshold" do + now = DateTime.utc_now() + older = DateTime.add(now, -3600, :second) + assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, older) + end + + test "returns true when packet equals threshold" do + now = DateTime.utc_now() + assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, now) + end + + test "returns false when packet is older than threshold" do + now = DateTime.utc_now() + older = DateTime.add(now, -3600, :second) + refute PacketUtils.packet_within_time_threshold?(%{received_at: older}, now) + end + + test "treats packet with no received_at as within threshold" do + assert PacketUtils.packet_within_time_threshold?(%{}, DateTime.utc_now()) + end + + test "accepts integer epoch threshold" do + now = DateTime.utc_now() + older_epoch = DateTime.to_unix(now) - 3600 + assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, older_epoch) + end + + test "accepts ISO8601 string threshold" do + now = DateTime.utc_now() + older = now |> DateTime.add(-3600, :second) |> DateTime.to_iso8601() + assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, older) + end + end + + describe "get_timestamp/1" do + test "returns ISO8601 for DateTime" do + dt = ~U[2024-01-15 12:00:00Z] + assert PacketUtils.get_timestamp(%{received_at: dt}) == "2024-01-15T12:00:00Z" + end + + test "returns ISO8601 for NaiveDateTime" do + ndt = ~N[2024-01-15 12:00:00] + result = PacketUtils.get_timestamp(%{received_at: ndt}) + assert result =~ "2024-01-15T12:00:00" + end + + test "reformats ISO8601 strings" do + # Any valid ISO8601 string parses and re-emits. + assert PacketUtils.get_timestamp(%{received_at: "2024-01-15T12:00:00Z"}) == + "2024-01-15T12:00:00Z" + end + + test "passes through unparseable strings" do + assert PacketUtils.get_timestamp(%{received_at: "whenever"}) == "whenever" + end + + test "returns empty string for nil or missing" do + assert PacketUtils.get_timestamp(%{received_at: nil}) == "" + assert PacketUtils.get_timestamp(%{}) == "" + end + end + + describe "generate_callsign/1" do + test "joins base_callsign and ssid with a dash" do + assert PacketUtils.generate_callsign(%{base_callsign: "K5ABC", ssid: "9"}) == "K5ABC-9" + end + + test "returns base_callsign only when ssid is nil" do + assert PacketUtils.generate_callsign(%{base_callsign: "K5ABC", ssid: nil}) == "K5ABC" + end + + test "returns base_callsign only when ssid is empty" do + assert PacketUtils.generate_callsign(%{base_callsign: "K5ABC", ssid: ""}) == "K5ABC" + end + + test "returns empty when base_callsign is missing" do + assert PacketUtils.generate_callsign(%{}) == "" + end + end + + describe "display_name/1" do + test "prefers object_name over everything" do + packet = %{sender: "K5ABC", object_name: "REP", item_name: "ITEM"} + assert PacketUtils.display_name(packet) == "REP" + end + + test "falls back to item_name when no object_name" do + packet = %{sender: "K5ABC", item_name: "ITEM"} + assert PacketUtils.display_name(packet) == "ITEM" + end + + test "falls back to sender when neither is present" do + assert PacketUtils.display_name(%{sender: "K5ABC"}) == "K5ABC" + end + + test "strips whitespace from names" do + assert PacketUtils.display_name(%{object_name: " REP "}) == "REP" + end + + test "ignores blank object_name/item_name (treated as absent)" do + packet = %{sender: "K5ABC", object_name: " ", item_name: ""} + assert PacketUtils.display_name(packet) == "K5ABC" + end + end + + describe "map_label/1" do + test "returns object_name for data_type=object" do + packet = %{data_type: "object", object_name: "REP", sender: "K5ABC"} + assert PacketUtils.map_label(packet) == "REP" + end + + test "returns item_name for data_type=item" do + packet = %{data_type: "item", item_name: "EVENT", sender: "K5ABC"} + assert PacketUtils.map_label(packet) == "EVENT" + end + + test "falls back to sender when object_name empty on object type" do + packet = %{data_type: "object", object_name: "", sender: "K5ABC"} + assert PacketUtils.map_label(packet) == "K5ABC" + end + + test "returns sender for position packets" do + packet = %{data_type: "position", sender: "K5ABC"} + assert PacketUtils.map_label(packet) == "K5ABC" + end + end + + describe "filter_packets_by_time_and_bounds/3" do + test "filters by both predicates" do + now = DateTime.utc_now() + older = DateTime.add(now, -3600, :second) + threshold = DateTime.add(now, -1800, :second) + bounds = %{north: 50.0, south: 30.0, east: -90.0, west: -110.0} + + packets = %{ + "in_and_recent" => %{lat: 40.0, lon: -100.0, received_at: now}, + "in_but_old" => %{lat: 40.0, lon: -100.0, received_at: older}, + "out_and_recent" => %{lat: 0.0, lon: 0.0, received_at: now} + } + + result = PacketUtils.filter_packets_by_time_and_bounds(packets, bounds, threshold) + assert Map.keys(result) == ["in_and_recent"] + end + end +end diff --git a/test/aprsme_web/live/shared/param_utils_test.exs b/test/aprsme_web/live/shared/param_utils_test.exs new file mode 100644 index 0000000..cc8e567 --- /dev/null +++ b/test/aprsme_web/live/shared/param_utils_test.exs @@ -0,0 +1,279 @@ +defmodule AprsmeWeb.Live.Shared.ParamUtilsTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias AprsmeWeb.Live.Shared.ParamUtils + + describe "parse_float_in_range/4" do + test "parses valid float inside range" do + assert ParamUtils.parse_float_in_range("30.5", 0.0, -90.0, 90.0) == 30.5 + end + + test "returns default when input is outside range" do + assert ParamUtils.parse_float_in_range("200", 0.0, -90.0, 90.0) == 0.0 + assert ParamUtils.parse_float_in_range("-200", 0.0, -90.0, 90.0) == 0.0 + end + + test "returns default for unparseable input" do + assert ParamUtils.parse_float_in_range("abc", 0.0, -90.0, 90.0) == 0.0 + assert ParamUtils.parse_float_in_range("", 0.0, -90.0, 90.0) == 0.0 + end + + test "returns default for non-binary input" do + assert ParamUtils.parse_float_in_range(nil, 1.5, -90.0, 90.0) == 1.5 + assert ParamUtils.parse_float_in_range(42, 1.5, -90.0, 90.0) == 1.5 + end + + test "accepts negative values in range" do + assert ParamUtils.parse_float_in_range("-45.25", 0.0, -90.0, 90.0) == -45.25 + end + + test "accepts values at exact range boundaries" do + assert ParamUtils.parse_float_in_range("90.0", 0.0, -90.0, 90.0) == 90.0 + assert ParamUtils.parse_float_in_range("-90.0", 0.0, -90.0, 90.0) == -90.0 + end + + test "accepts input with trailing non-numeric characters if still in range" do + # This mirrors documented behavior in the implementation. + assert ParamUtils.parse_float_in_range("30.5abc", 0.0, -90.0, 90.0) == 30.5 + end + end + + describe "parse_int_in_range/4" do + test "parses valid integer inside range" do + assert ParamUtils.parse_int_in_range("10", 5, 1, 20) == 10 + end + + test "returns default for out-of-range value" do + assert ParamUtils.parse_int_in_range("100", 5, 1, 20) == 5 + assert ParamUtils.parse_int_in_range("0", 5, 1, 20) == 5 + end + + test "returns default for unparseable value" do + assert ParamUtils.parse_int_in_range("foo", 5, 1, 20) == 5 + assert ParamUtils.parse_int_in_range(nil, 5, 1, 20) == 5 + end + + test "accepts boundary values" do + assert ParamUtils.parse_int_in_range("1", 5, 1, 20) == 1 + assert ParamUtils.parse_int_in_range("20", 5, 1, 20) == 20 + end + end + + describe "sanitize_numeric_string/1" do + test "trims whitespace" do + assert ParamUtils.sanitize_numeric_string(" 42 ") == "42" + end + + test "strips non-numeric characters" do + assert ParamUtils.sanitize_numeric_string("1