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:
parent
1ad1bef59f
commit
403300f696
31 changed files with 3462 additions and 128 deletions
158
test/aprsme/accounts/user_token_test.exs
Normal file
158
test/aprsme/accounts/user_token_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
52
test/aprsme/cleanup_scheduler_test.exs
Normal file
52
test/aprsme/cleanup_scheduler_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
43
test/aprsme/deployment_notifier_test.exs
Normal file
43
test/aprsme/deployment_notifier_test.exs
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
114
test/aprsme/device_parser_test.exs
Normal file
114
test/aprsme/device_parser_test.exs
Normal 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
|
||||
258
test/aprsme/log_sanitizer_test.exs
Normal file
258
test/aprsme/log_sanitizer_test.exs
Normal 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
|
||||
130
test/aprsme/packet_field_whitelist_test.exs
Normal file
130
test/aprsme/packet_field_whitelist_test.exs
Normal 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
|
||||
BIN
test/aprsme/packet_sanitizer_test.exs
Normal file
BIN
test/aprsme/packet_sanitizer_test.exs
Normal file
Binary file not shown.
59
test/aprsme/regex_cache_test.exs
Normal file
59
test/aprsme/regex_cache_test.exs
Normal 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
|
||||
63
test/aprsme/release_test.exs
Normal file
63
test/aprsme/release_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
208
test/aprsme_web/controllers/api/v1/json/callsign_json_test.exs
Normal file
208
test/aprsme_web/controllers/api/v1/json/callsign_json_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
53
test/aprsme_web/controllers/api/v1/json/error_json_test.exs
Normal file
53
test/aprsme_web/controllers/api/v1/json/error_json_test.exs
Normal 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
|
||||
90
test/aprsme_web/live/components/symbol_renderer_test.exs
Normal file
90
test/aprsme_web/live/components/symbol_renderer_test.exs
Normal 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 ">"
|
||||
assert html =~ ~s(title="/>")
|
||||
end
|
||||
|
||||
test "passes additional class through" do
|
||||
html =
|
||||
render_component(&SymbolRenderer.symbol/1,
|
||||
symbol_table: "/",
|
||||
symbol_code: ">",
|
||||
class: "extra-class"
|
||||
)
|
||||
|
||||
assert html =~ "extra-class"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = %{
|
||||
|
|
|
|||
95
test/aprsme_web/live/map_live/url_params_test.exs
Normal file
95
test/aprsme_web/live/map_live/url_params_test.exs
Normal 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
|
||||
237
test/aprsme_web/live/shared/bounds_utils_test.exs
Normal file
237
test/aprsme_web/live/shared/bounds_utils_test.exs
Normal 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
|
||||
304
test/aprsme_web/live/shared/coordinate_utils_test.exs
Normal file
304
test/aprsme_web/live/shared/coordinate_utils_test.exs
Normal 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
|
||||
339
test/aprsme_web/live/shared/packet_utils_test.exs
Normal file
339
test/aprsme_web/live/shared/packet_utils_test.exs
Normal 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
|
||||
279
test/aprsme_web/live/shared/param_utils_test.exs
Normal file
279
test/aprsme_web/live/shared/param_utils_test.exs
Normal 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
|
||||
108
test/aprsme_web/live/theme_manager_test.exs
Normal file
108
test/aprsme_web/live/theme_manager_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
77
test/aprsme_web/plugs/health_check_test.exs
Normal file
77
test/aprsme_web/plugs/health_check_test.exs
Normal 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
|
||||
155
test/aprsme_web/plugs/rate_limiter_test.exs
Normal file
155
test/aprsme_web/plugs/rate_limiter_test.exs
Normal 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
|
||||
90
test/aprsme_web/time_utils_test.exs
Normal file
90
test/aprsme_web/time_utils_test.exs
Normal 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
|
||||
108
test/aprsme_web/weather_units_test.exs
Normal file
108
test/aprsme_web/weather_units_test.exs
Normal 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
|
||||
7
test/support/fake_sensitive_struct.ex
Normal file
7
test/support/fake_sensitive_struct.ex
Normal 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
|
||||
Loading…
Add table
Reference in a new issue