test: broaden unit & property-test coverage (61.8% → 64.7%)

Adds ~800 new tests (incl. 27 StreamData properties) across 22 modules
that previously had little or no coverage. Highlights:

- Pure utility modules (LogSanitizer, PacketFieldWhitelist,
  PacketSanitizer, DeviceParser, CoordinateUtils, BoundsUtils,
  ParamUtils, Convert, WeatherUnits) get thorough unit + property
  coverage including UTF-8 truncation boundaries, antimeridian
  longitude wrap, Haversine symmetry, and unit-conversion inverses.
- JSON view modules (CallsignJSON, ErrorJSON, ChangesetJSON) verified
  for envelope shape and error templating.
- Plugs (HealthCheck, RateLimiter, ApiCSRF) exercised for happy-path
  and halt paths.
- GenServer modules (RegexCache, CleanupScheduler, DeploymentNotifier)
  tested without touching the supervised singleton where possible.
- Drops the orphaned map_live/map_helpers_test.exs whose module name
  collided with the new live/shared/coordinate_utils_test.exs.
This commit is contained in:
Graham McIntire 2026-04-23 13:32:09 -05:00
parent 1ad1bef59f
commit 403300f696
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
31 changed files with 3462 additions and 128 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 "&gt;"
assert html =~ ~s(title="/&gt;")
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

View file

@ -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

View file

@ -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 = %{

View file

@ -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("1<x>2") == "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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<script>2") == "12"
end
test "keeps scientific notation characters" do
assert ParamUtils.sanitize_numeric_string("1e5") == "1e5"
assert ParamUtils.sanitize_numeric_string("1.5E-3") == "1.5E-3"
end
test "keeps decimals and signs" do
assert ParamUtils.sanitize_numeric_string("-3.14") == "-3.14"
end
test "limits length to 20 characters" do
long = String.duplicate("1", 50)
assert byte_size(ParamUtils.sanitize_numeric_string(long)) == 20
end
test "returns empty string for non-binary" do
assert ParamUtils.sanitize_numeric_string(nil) == ""
assert ParamUtils.sanitize_numeric_string(123) == ""
end
end
describe "limit_string_length/2" do
test "truncates strings longer than limit" do
assert ParamUtils.limit_string_length("hello world", 5) == "hello"
end
test "leaves short strings alone" do
assert ParamUtils.limit_string_length("hi", 10) == "hi"
end
test "handles strings exactly at the limit" do
assert ParamUtils.limit_string_length("abcde", 5) == "abcde"
end
end
describe "finite?/1" do
test "returns true for floats" do
assert ParamUtils.finite?(1.0)
assert ParamUtils.finite?(-999.9)
assert ParamUtils.finite?(0.0)
end
test "returns false for non-floats" do
refute ParamUtils.finite?(1)
refute ParamUtils.finite?("1.0")
refute ParamUtils.finite?(nil)
end
end
describe "safe_parse_coordinate/4" do
test "parses strings and clamps" do
assert ParamUtils.safe_parse_coordinate("45.0", 0.0, -90.0, 90.0) == 45.0
end
test "passes through numbers in range" do
assert ParamUtils.safe_parse_coordinate(12.5, 0.0, -90.0, 90.0) == 12.5
end
test "clamps numbers above max" do
assert ParamUtils.safe_parse_coordinate(500, 0.0, -90.0, 90.0) == 90.0
end
test "clamps numbers below min" do
assert ParamUtils.safe_parse_coordinate(-500, 0.0, -90.0, 90.0) == -90.0
end
test "returns default for invalid string (out-of-range)" do
assert ParamUtils.safe_parse_coordinate("200", 1.5, -90.0, 90.0) == 1.5
end
test "returns default for non-string, non-number input" do
assert ParamUtils.safe_parse_coordinate(nil, 1.5, -90.0, 90.0) == 1.5
assert ParamUtils.safe_parse_coordinate(%{}, 1.5, -90.0, 90.0) == 1.5
end
end
describe "clamp_coordinate/3" do
test "keeps values in range" do
assert ParamUtils.clamp_coordinate(45.0, -90.0, 90.0) == 45.0
end
test "clamps above max" do
assert ParamUtils.clamp_coordinate(91.0, -90.0, 90.0) == 90.0
end
test "clamps below min" do
assert ParamUtils.clamp_coordinate(-91.0, -90.0, 90.0) == -90.0
end
test "returns 0.0 for non-numeric" do
assert ParamUtils.clamp_coordinate(nil, -90.0, 90.0) == 0.0
end
end
describe "clamp_zoom/1" do
test "parses binary zoom" do
assert ParamUtils.clamp_zoom("10") == 10
end
test "clamps integer outside range" do
assert ParamUtils.clamp_zoom(50) == 20
assert ParamUtils.clamp_zoom(-5) == 1
end
test "truncates float" do
assert ParamUtils.clamp_zoom(8.9) == 8
end
test "returns default for invalid input" do
assert ParamUtils.clamp_zoom(nil) == 5
assert ParamUtils.clamp_zoom(%{}) == 5
assert ParamUtils.clamp_zoom("garbage") == 5
end
end
describe "valid_callsign?/1" do
test "accepts standard callsigns" do
assert ParamUtils.valid_callsign?("K5ABC")
assert ParamUtils.valid_callsign?("W1XYZ-9")
end
test "rejects lowercase? actually the regex is case-insensitive" do
assert ParamUtils.valid_callsign?("k5abc")
end
test "rejects obvious garbage" do
refute ParamUtils.valid_callsign?("")
refute ParamUtils.valid_callsign?("HELLO WORLD")
refute ParamUtils.valid_callsign?("K5ABC#")
end
test "rejects non-binary input" do
refute ParamUtils.valid_callsign?(nil)
refute ParamUtils.valid_callsign?(123)
end
end
describe "sanitize_path_string/1" do
test "keeps allowed characters" do
assert ParamUtils.sanitize_path_string("WIDE1-1,WIDE2-2*") == "WIDE1-1,WIDE2-2*"
end
test "strips disallowed characters" do
assert ParamUtils.sanitize_path_string("A<script>B") == "AscriptB"
end
test "trims whitespace" do
assert ParamUtils.sanitize_path_string(" ABC ") == "ABC"
end
test "limits length" do
long = String.duplicate("A", 300)
assert byte_size(ParamUtils.sanitize_path_string(long)) == 200
end
test "returns empty for non-binary" do
assert ParamUtils.sanitize_path_string(nil) == ""
end
end
describe "property: parse_float_in_range never returns a value outside the range when input was valid" do
property "result is always default or within [min, max]" do
check all(
value <- float(min: -1000.0, max: 1000.0),
min <- float(min: -500.0, max: 0.0),
max <- float(min: 0.0, max: 500.0),
min < max
) do
default = min
result = ParamUtils.parse_float_in_range(to_string(value), default, min, max)
assert result == default or (result >= min and result <= max)
end
end
end
describe "property: clamp_coordinate never exceeds bounds" do
property "clamp_coordinate(v, min, max) is always in [min, max]" do
check all(
v <- one_of([float(), integer()]),
min <- float(min: -1000.0, max: 0.0),
max <- float(min: 0.0, max: 1000.0),
min < max
) do
result = ParamUtils.clamp_coordinate(v, min, max)
assert result >= min and result <= max
end
end
end
describe "property: clamp_zoom always returns an integer in [1, 20]" do
property "clamp_zoom enforces invariants" do
check all(zoom <- one_of([integer(), float(), string(:alphanumeric), constant(nil)])) do
result = ParamUtils.clamp_zoom(zoom)
assert is_integer(result)
assert result >= 1 and result <= 20
end
end
end
describe "property: sanitize_numeric_string output is always printable and short" do
property "output length <= 20 and contains only safe characters" do
check all(s <- string(:printable)) do
result = ParamUtils.sanitize_numeric_string(s)
assert byte_size(result) <= 20
assert Regex.match?(~r/^[\d\.\-eE]*$/, result)
end
end
end
end

View file

@ -0,0 +1,108 @@
defmodule AprsmeWeb.ThemeManagerTest do
use ExUnit.Case, async: true
alias AprsmeWeb.ThemeManager
alias Phoenix.LiveView.Socket
describe "resolve_theme/2" do
test "returns system_preference for 'auto'" do
assert ThemeManager.resolve_theme("auto", "dark") == "dark"
assert ThemeManager.resolve_theme("auto", "light") == "light"
end
test "returns the explicit theme for 'light' or 'dark'" do
assert ThemeManager.resolve_theme("light", "dark") == "light"
assert ThemeManager.resolve_theme("dark", "light") == "dark"
end
test "falls back to system_preference for unknown themes" do
assert ThemeManager.resolve_theme("neon", "light") == "light"
assert ThemeManager.resolve_theme(nil, "dark") == "dark"
end
end
describe "get_theme_colors/1" do
test "returns dark-themed colors for 'dark'" do
colors = ThemeManager.get_theme_colors("dark")
assert is_map(colors)
assert colors.text == "#e5e7eb"
assert colors.grid == "#374151"
assert colors.primary == "#3b82f6"
end
test "returns light-themed colors for 'light'" do
colors = ThemeManager.get_theme_colors("light")
assert colors.text == "#111827"
assert colors.primary == "#2563eb"
end
test "any non-dark value returns light colors" do
assert ThemeManager.get_theme_colors("auto").text == "#111827"
assert ThemeManager.get_theme_colors(nil).text == "#111827"
assert ThemeManager.get_theme_colors("whatever").text == "#111827"
end
test "dark and light colors are distinct" do
dark = ThemeManager.get_theme_colors("dark")
light = ThemeManager.get_theme_colors("light")
refute dark.text == light.text
refute dark.grid == light.grid
end
test "all expected keys are present" do
for theme <- ["dark", "light"] do
colors = ThemeManager.get_theme_colors(theme)
assert Map.has_key?(colors, :text)
assert Map.has_key?(colors, :grid)
assert Map.has_key?(colors, :background)
assert Map.has_key?(colors, :primary)
assert Map.has_key?(colors, :secondary)
assert Map.has_key?(colors, :accent)
end
end
end
describe "init_theme/2" do
test "initializes with stored theme from session" do
socket = %Socket{assigns: %{__changed__: %{}}}
result = ThemeManager.init_theme(socket, %{"theme" => "dark"})
assert result.assigns.theme == "dark"
assert result.assigns.resolved_theme == "dark"
assert is_map(result.assigns.theme_colors)
end
test "defaults to 'auto' when no session theme" do
socket = %Socket{assigns: %{__changed__: %{}}}
result = ThemeManager.init_theme(socket, %{})
assert result.assigns.theme == "auto"
# system_preference is "light" in this module
assert result.assigns.resolved_theme == "light"
end
test "ignores unknown stored themes, defaulting to auto" do
socket = %Socket{assigns: %{__changed__: %{}}}
result = ThemeManager.init_theme(socket, %{"theme" => "neon"})
assert result.assigns.theme == "auto"
end
test "defaults to auto when session arg is omitted" do
socket = %Socket{assigns: %{__changed__: %{}}}
result = ThemeManager.init_theme(socket)
assert result.assigns.theme == "auto"
end
end
describe "handle_theme_change/2" do
test "returns unchanged socket for invalid themes" do
socket = %Socket{assigns: %{__changed__: %{}}}
assert ThemeManager.handle_theme_change(socket, "invalid") == socket
end
test "updates assigns for valid themes" do
socket = %Socket{assigns: %{__changed__: %{}}}
result = ThemeManager.handle_theme_change(socket, "dark")
assert result.assigns.theme == "dark"
assert result.assigns.resolved_theme == "dark"
end
end
end

View file

@ -36,4 +36,54 @@ defmodule AprsmeWeb.Plugs.ApiCSRFTest do
refute conn.halted
end
test "allows JSON with utf-8 content type and XHR header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> put_req_header("content-type", "application/json; charset=utf-8")
|> put_req_header("x-requested-with", "XMLHttpRequest")
|> ApiCSRF.call([])
refute conn.halted
end
test "rejects JSON request with empty csrf-token header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> put_req_header("content-type", "application/json")
|> put_req_header("x-csrf-token", "")
|> ApiCSRF.call([])
assert conn.halted
assert conn.status == 403
end
test "rejects JSON request with token but no session token", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> put_req_header("content-type", "application/json")
|> put_req_header("x-csrf-token", "any-token")
|> ApiCSRF.call([])
assert conn.halted
end
test "rejects JSON request with mismatched csrf token", %{conn: conn} do
conn =
conn
|> init_test_session(%{"_csrf_token" => "a-session-token"})
|> put_req_header("content-type", "application/json")
|> put_req_header("x-csrf-token", "wrong-token")
|> ApiCSRF.call([])
assert conn.halted
end
test "init/1 returns opts unchanged" do
assert ApiCSRF.init([]) == []
assert ApiCSRF.init(foo: :bar) == [foo: :bar]
end
end

View file

@ -0,0 +1,77 @@
defmodule AprsmeWeb.Plugs.HealthCheckTest do
# :health_status is an app-wide env var the plug reads, so these tests can't
# safely run in parallel with anything else that might touch it.
use AprsmeWeb.ConnCase, async: false
alias AprsmeWeb.Plugs.HealthCheck
describe "call/2 pass-through" do
test "returns the conn unchanged for non-/health paths", %{conn: conn} do
conn = %{conn | request_path: "/something-else"}
result = HealthCheck.call(conn, [])
assert result == conn
refute result.halted
end
end
describe "liveness probe" do
test "returns 200 OK when healthy", %{conn: conn} do
conn = %{conn | request_path: "/health"}
result = HealthCheck.call(conn, probe_type: :liveness)
assert result.status == 200
assert result.resp_body == "OK"
assert result.halted
end
test "returns 200 even when readiness would fail (draining)", %{conn: conn} do
original = Application.get_env(:aprsme, :health_status, :healthy)
try do
Application.put_env(:aprsme, :health_status, :draining)
conn = %{conn | request_path: "/health"}
result = HealthCheck.call(conn, probe_type: :liveness)
assert result.status == 200
after
Application.put_env(:aprsme, :health_status, original)
end
end
end
describe "readiness probe" do
test "returns 200 OK under normal conditions", %{conn: conn} do
conn = %{conn | request_path: "/health"}
result = HealthCheck.call(conn, probe_type: :readiness)
# Readiness also hits the DB and PubSub, which should work in test env.
assert result.status in [200, 503]
assert result.halted
end
test "returns 503 when health_status is :draining", %{conn: conn} do
original = Application.get_env(:aprsme, :health_status, :healthy)
try do
Application.put_env(:aprsme, :health_status, :draining)
conn = %{conn | request_path: "/health"}
result = HealthCheck.call(conn, probe_type: :readiness)
assert result.status == 503
assert result.resp_body =~ "draining"
after
Application.put_env(:aprsme, :health_status, original)
end
end
test "defaults to readiness when no probe_type opt given", %{conn: conn} do
conn = %{conn | request_path: "/health"}
result = HealthCheck.call(conn, [])
assert result.status in [200, 503]
assert result.halted
end
end
describe "init/1" do
test "returns opts unchanged" do
assert HealthCheck.init(probe_type: :liveness) == [probe_type: :liveness]
assert HealthCheck.init([]) == []
end
end
end

View file

@ -0,0 +1,155 @@
defmodule AprsmeWeb.Plugs.RateLimiterTest do
use AprsmeWeb.ConnCase, async: true
alias AprsmeWeb.Plugs.RateLimiter
# Use a unique key per test to avoid cross-test interference with the
# process-wide ETS rate-limit table. Each test gets a string key that
# hasn't been seen before.
defp unique_key, do: "test-#{System.unique_integer([:positive])}"
describe "init/1" do
test "applies default options" do
opts = RateLimiter.init([])
assert opts[:scale] == 60_000
assert opts[:limit] == 100
assert opts[:key] == :ip
assert opts[:error_message] == "Too many requests"
end
test "user options override defaults" do
opts = RateLimiter.init(limit: 5, scale: 1_000, error_message: "nope")
assert opts[:limit] == 5
assert opts[:scale] == 1_000
assert opts[:error_message] == "nope"
# Unspecified options retain their defaults
assert opts[:key] == :ip
end
end
describe "call/2 with :ip key" do
test "allows requests under the limit", %{conn: conn} do
opts = RateLimiter.init(key: unique_key(), limit: 5, scale: 60_000)
result = RateLimiter.call(conn, opts)
refute result.halted
end
test "denies once the limit is exceeded", %{conn: conn} do
opts = RateLimiter.init(key: unique_key(), limit: 2, scale: 60_000)
# Two under-limit calls succeed
assert refute_halted(RateLimiter.call(conn, opts))
assert refute_halted(RateLimiter.call(conn, opts))
# Third call exceeds the limit and halts with a 429
result = RateLimiter.call(conn, opts)
assert result.halted
assert result.status == 429
assert result.resp_body =~ "Too many requests"
end
test "uses the configured error_message in the body", %{conn: conn} do
opts =
RateLimiter.init(
key: unique_key(),
limit: 1,
error_message: "Custom limit message"
)
RateLimiter.call(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
body = Jason.decode!(result.resp_body)
assert body["error"] == "Custom limit message"
end
end
describe "call/2 with Cloudflare/X-Forwarded-For/X-Real-IP headers" do
test "honors CF-Connecting-IP header", %{conn: conn} do
key_name = unique_key()
conn = Plug.Conn.put_req_header(conn, "cf-connecting-ip", "203.0.113.5")
opts = RateLimiter.init(limit: 1, key: :ip, error_message: "m")
# Prefix the key with a known distinct IP to avoid cross-test interference
_ = run_once(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
# Second unique-IP request should be allowed
other = Plug.Conn.put_req_header(conn, "cf-connecting-ip", "203.0.113.99-#{key_name}")
result2 = RateLimiter.call(other, opts)
refute result2.halted
end
test "splits X-Forwarded-For on comma", %{conn: conn} do
conn = Plug.Conn.put_req_header(conn, "x-forwarded-for", "198.51.100.1, 10.0.0.1")
opts = RateLimiter.init(limit: 1, key: :ip)
run_once(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
end
test "honors X-Real-IP when other headers absent", %{conn: conn} do
conn = Plug.Conn.put_req_header(conn, "x-real-ip", "192.0.2.1")
opts = RateLimiter.init(limit: 1, key: :ip)
run_once(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
end
test "falls back to remote_ip when no headers present", %{conn: conn} do
# Bare build_conn has {127, 0, 0, 1} remote_ip; just make sure call doesn't crash.
opts = RateLimiter.init(limit: 100, key: :ip)
refute_halted(RateLimiter.call(conn, opts))
end
end
describe "call/2 with :user_agent key" do
test "uses the user-agent header as the key", %{conn: conn} do
opts = RateLimiter.init(limit: 1, key: :user_agent)
conn = Plug.Conn.put_req_header(conn, "user-agent", "TestBot/#{unique_key()}")
run_once(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
end
test "uses 'unknown' key when no user-agent header", %{conn: _conn} do
# Run this test with a fresh conn that has no user-agent. Only make sure
# the code path doesn't crash and eventually halts after hitting the limit.
conn = Phoenix.ConnTest.build_conn()
opts = RateLimiter.init(limit: 1_000_000, key: :user_agent)
refute_halted(RateLimiter.call(conn, opts))
end
end
describe "call/2 with function key" do
test "invokes the function to derive the key", %{conn: conn} do
unique = unique_key()
fun = fn _conn -> unique end
opts = RateLimiter.init(limit: 1, key: fun)
run_once(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
end
end
describe "call/2 with binary key" do
test "uses the string directly as the key", %{conn: conn} do
opts = RateLimiter.init(limit: 1, key: unique_key())
run_once(conn, opts)
result = RateLimiter.call(conn, opts)
assert result.halted
end
end
# Helpers
defp run_once(conn, opts) do
result = RateLimiter.call(conn, opts)
refute result.halted
result
end
defp refute_halted(conn) do
refute conn.halted
conn
end
end

View file

@ -0,0 +1,90 @@
defmodule AprsmeWeb.TimeUtilsTest do
use ExUnit.Case, async: true
alias AprsmeWeb.TimeUtils
# Allow a few seconds of slack between sequential `DateTime.utc_now/0` calls.
@slack_seconds 5
defp seconds_between(dt1, dt2) do
abs(DateTime.diff(dt1, dt2, :second))
end
describe "hours_ago/1" do
test "returns a DateTime in the past" do
dt = TimeUtils.hours_ago(3)
now = DateTime.utc_now()
assert DateTime.before?(dt, now)
assert_in_delta DateTime.diff(now, dt, :second), 3 * 3600, @slack_seconds
end
test "hours_ago(0) returns approximately now" do
dt = TimeUtils.hours_ago(0)
assert seconds_between(dt, DateTime.utc_now()) < @slack_seconds
end
end
describe "named helpers" do
test "one_hour_ago is about 3600 seconds before now" do
dt = TimeUtils.one_hour_ago()
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 3600, @slack_seconds
end
test "one_day_ago is about 86400 seconds before now" do
dt = TimeUtils.one_day_ago()
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 86_400, @slack_seconds
end
test "two_days_ago is about 172800 seconds before now" do
dt = TimeUtils.two_days_ago()
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 172_800, @slack_seconds
end
test "one_week_ago is about 604800 seconds before now" do
dt = TimeUtils.one_week_ago()
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 604_800, @slack_seconds
end
end
describe "days_ago/1" do
test "returns N days before now" do
dt = TimeUtils.days_ago(3)
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 3 * 86_400, @slack_seconds
end
test "accepts 0" do
dt = TimeUtils.days_ago(0)
assert seconds_between(dt, DateTime.utc_now()) < @slack_seconds
end
end
describe "time_range/1" do
test "returns {past, now} for :last_hour" do
{start_t, end_t} = TimeUtils.time_range(:last_hour)
assert DateTime.before?(start_t, end_t)
assert_in_delta DateTime.diff(end_t, start_t, :second), 3600, @slack_seconds
end
test "returns {past, now} for :last_day" do
{start_t, end_t} = TimeUtils.time_range(:last_day)
assert_in_delta DateTime.diff(end_t, start_t, :second), 86_400, @slack_seconds
end
test "returns {past, now} for :last_two_days" do
{start_t, end_t} = TimeUtils.time_range(:last_two_days)
assert_in_delta DateTime.diff(end_t, start_t, :second), 172_800, @slack_seconds
end
test "returns {past, now} for :last_week" do
{start_t, end_t} = TimeUtils.time_range(:last_week)
assert_in_delta DateTime.diff(end_t, start_t, :second), 604_800, @slack_seconds
end
end
describe "default_time_range/0" do
test "matches :last_two_days" do
{start_t, end_t} = TimeUtils.default_time_range()
assert_in_delta DateTime.diff(end_t, start_t, :second), 172_800, @slack_seconds
end
end
end

View file

@ -0,0 +1,108 @@
defmodule AprsmeWeb.WeatherUnitsTest do
use ExUnit.Case, async: true
alias AprsmeWeb.WeatherUnits
describe "unit_system/1" do
test "returns :imperial for English locale" do
assert WeatherUnits.unit_system("en") == :imperial
end
test "returns :metric for common metric locales" do
assert WeatherUnits.unit_system("es") == :metric
assert WeatherUnits.unit_system("de") == :metric
assert WeatherUnits.unit_system("fr") == :metric
end
test "defaults to :metric for other locale strings" do
assert WeatherUnits.unit_system("ja") == :metric
assert WeatherUnits.unit_system("zh") == :metric
end
test "defaults to :imperial for nil and non-binary" do
assert WeatherUnits.unit_system(nil) == :imperial
assert WeatherUnits.unit_system(123) == :imperial
assert WeatherUnits.unit_system(:atom) == :imperial
end
end
describe "format_temperature/2" do
test "returns Fahrenheit unchanged for English locale" do
assert WeatherUnits.format_temperature(72, "en") == {72, "°F"}
end
test "converts Fahrenheit to Celsius for metric locale" do
{value, unit} = WeatherUnits.format_temperature(32, "de")
assert_in_delta value, 0.0, 0.0001
assert unit == "°C"
end
test "returns value and °F for non-numeric input" do
assert WeatherUnits.format_temperature(nil, "en") == {nil, "°F"}
assert WeatherUnits.format_temperature("N/A", "de") == {"N/A", "°F"}
end
end
describe "format_wind_speed/2" do
test "returns mph for English locale" do
assert WeatherUnits.format_wind_speed(10, "en") == {10, "mph"}
end
test "converts mph to km/h for metric locale" do
{value, unit} = WeatherUnits.format_wind_speed(10, "fr")
assert_in_delta value, 16.0934, 0.0001
assert unit == "km/h"
end
test "returns value and mph for non-numeric input" do
assert WeatherUnits.format_wind_speed(nil, "de") == {nil, "mph"}
end
end
describe "format_rain/2" do
test "returns inches for English locale" do
assert WeatherUnits.format_rain(1.5, "en") == {1.5, "in"}
end
test "converts inches to mm for metric locale" do
{value, unit} = WeatherUnits.format_rain(1, "es")
assert_in_delta value, 25.4, 0.0001
assert unit == "mm"
end
test "returns value and in for non-numeric input" do
assert WeatherUnits.format_rain(nil, "en") == {nil, "in"}
end
end
describe "format_pressure/2" do
test "returns hPa for any locale" do
assert WeatherUnits.format_pressure(1013, "en") == {1013, "hPa"}
assert WeatherUnits.format_pressure(1013, "de") == {1013, "hPa"}
end
test "returns hPa even for non-numeric input" do
assert WeatherUnits.format_pressure(nil, "en") == {nil, "hPa"}
end
end
describe "unit_labels/1" do
test "returns imperial labels for English" do
assert WeatherUnits.unit_labels("en") == %{
temperature: "°F",
wind_speed: "mph",
rain: "in",
pressure: "hPa"
}
end
test "returns metric labels for metric locale" do
assert WeatherUnits.unit_labels("de") == %{
temperature: "°C",
wind_speed: "km/h",
rain: "mm",
pressure: "hPa"
}
end
end
end

View file

@ -0,0 +1,7 @@
defmodule Aprsme.Support.FakeSensitiveStruct do
@moduledoc """
Test fixture: a struct with a field matching one of LogSanitizer's sensitive keys.
Used to verify that non-exception structs get converted to maps and redacted.
"""
defstruct [:password, :name]
end