test: cover auth LiveViews, PacketReplay, ShutdownHandler and more

This commit is contained in:
Graham McIntire 2026-04-23 16:30:47 -05:00
parent 3ab167777e
commit 41731257e6
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
14 changed files with 865 additions and 4 deletions

View file

@ -93,4 +93,115 @@ defmodule Aprsme.Accounts.UserTest do
assert get_change(changeset, :callsign) == "W2XYZ"
end
end
describe "email_changeset/2" do
test "requires email to change" do
user = %User{email: "a@b.com"}
changeset = User.email_changeset(user, %{email: "a@b.com"})
errors = errors_on(changeset)
assert Map.has_key?(errors, :email)
assert "did not change" in errors.email
end
test "validates format of the new email" do
user = %User{email: "a@b.com"}
changeset = User.email_changeset(user, %{email: "bad email"})
errors = errors_on(changeset)
assert "must have the @ sign and no spaces" in errors.email
end
end
describe "password_changeset/2" do
test "requires matching confirmation" do
user = %User{}
changeset =
User.password_changeset(user, %{
"password" => "abcdefghijkl",
"password_confirmation" => "different"
})
errors = errors_on(changeset)
assert "does not match password" in errors.password_confirmation
end
test "hashes password when valid" do
user = %User{}
changeset =
User.password_changeset(user, %{
"password" => "abcdefghijkl",
"password_confirmation" => "abcdefghijkl"
})
assert changeset.valid?
assert is_binary(get_change(changeset, :hashed_password))
# Plaintext password is dropped after hashing.
refute get_change(changeset, :password)
end
end
describe "confirm_changeset/1" do
test "sets confirmed_at to the current time truncated to seconds" do
changeset = User.confirm_changeset(%User{})
confirmed_at = get_change(changeset, :confirmed_at)
assert %NaiveDateTime{} = confirmed_at
# No microseconds — truncated.
assert confirmed_at.microsecond == {0, 0}
end
end
describe "valid_password?/2" do
test "returns false when the user has no hashed password" do
refute User.valid_password?(%User{hashed_password: nil}, "any")
end
test "returns false when the provided password is empty" do
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
refute User.valid_password?(user, "")
end
test "returns true for the correct password" do
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
assert User.valid_password?(user, "hello world!")
end
test "returns false for the wrong password" do
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
refute User.valid_password?(user, "wrong")
end
end
describe "validate_current_password/2" do
test "adds an error when the password is wrong" do
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
changeset = Ecto.Changeset.change(user)
changeset = User.validate_current_password(changeset, "wrong")
errors = errors_on(changeset)
assert "is not valid" in errors.current_password
end
test "does not add an error when the password is correct" do
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
changeset = Ecto.Changeset.change(user)
changeset = User.validate_current_password(changeset, "hello world!")
assert changeset.valid?
end
end
describe "Inspect protocol" do
test "redacts the hashed and plaintext passwords" do
user = %User{
email: "a@b.com",
hashed_password: "super-secret-hash",
password: "super-secret"
}
output = inspect(user)
refute output =~ "super-secret-hash"
refute output =~ "super-secret"
# The email is non-redacted, so it should still appear.
assert output =~ "a@b.com"
end
end
end

View file

@ -155,4 +155,115 @@ defmodule Aprsme.PacketReplayTest do
assert {:ok, _state} = result2
end
end
describe "handle_call/3" do
setup do
# Flush any messages lingering from init send_self.
{:ok, state} = PacketReplay.init(user_id: "caller", bounds: [0, 0, 1, 1])
receive do
:start_replay -> :ok
after
0 -> :ok
end
{:ok, state: state}
end
test "pause cancels any pending timer and marks state paused", %{state: state} do
timer_ref = Process.send_after(self(), :noop, 60_000)
state = %{state | replay_timer: timer_ref}
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:pause, self(), state)
assert new_state.paused
assert is_nil(new_state.replay_timer)
end
test "resume from paused sends continue message and flips flag", %{state: state} do
state = %{state | paused: true}
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:resume, self(), state)
refute new_state.paused
assert_received {:continue_replay}
end
test "resume when already running is a no-op", %{state: state} do
assert {:reply, :ok, ^state} = PacketReplay.handle_call(:resume, self(), state)
end
test "set_speed updates the replay speed", %{state: state} do
assert {:reply, :ok, %{replay_speed: 7.5}} =
PacketReplay.handle_call({:set_speed, 7.5}, self(), state)
end
test "update_filters merges new keys into state", %{state: state} do
filters = [callsign: "K1ABC", replay_speed: 3.0]
assert {:reply, :ok, new_state} =
PacketReplay.handle_call({:update_filters, filters}, self(), state)
assert new_state.callsign == "K1ABC"
assert new_state.replay_speed == 3.0
assert new_state.packets_sent == 0
end
test "update_filters rejects invalid bounds and keeps the old bounds", %{state: state} do
filters = [bounds: "not a list"]
assert {:reply, :ok, new_state} =
PacketReplay.handle_call({:update_filters, filters}, self(), state)
# Should have reverted to the original bounds.
assert new_state.bounds == state.bounds
end
test "get_info returns a public view of state", %{state: state} do
assert {:reply, info, ^state} = PacketReplay.handle_call(:get_info, self(), state)
assert info.user_id == "caller"
assert info.bounds == [0, 0, 1, 1]
assert info.paused == false
assert info.packets_sent == 0
end
end
describe "handle_info/2 paused path" do
test "pause-time :send_packet reschedules via timer rather than broadcasting" do
{:ok, state} = PacketReplay.init(user_id: "paused", bounds: [0, 0, 1, 1])
receive do
:start_replay -> :ok
after
0 -> :ok
end
state = %{state | paused: true}
packet = %{received_at: DateTime.utc_now()}
stream = Stream.repeatedly(fn -> {0.0, packet} end)
assert {:noreply, new_state} =
PacketReplay.handle_info({:send_packet, packet, stream}, state)
# A new timer ref should be set for rescheduling.
assert is_reference(new_state.replay_timer)
Process.cancel_timer(new_state.replay_timer)
end
end
describe "terminate/2" do
test "cancels pending timer and returns :ok" do
{:ok, state} = PacketReplay.init(user_id: "terminated", bounds: [0, 0, 1, 1])
receive do
:start_replay -> :ok
after
0 -> :ok
end
timer_ref = Process.send_after(self(), :noop, 60_000)
state = %{state | replay_timer: timer_ref}
assert :ok = PacketReplay.terminate(:shutdown, state)
# Timer should have been cancelled.
assert Process.read_timer(timer_ref) == false
end
end
end

View file

@ -47,4 +47,71 @@ defmodule Aprsme.ShutdownHandlerTest do
assert is_boolean(ShutdownHandler.shutting_down?())
end
end
describe "init/1" do
test "traps exits and reads drain timeout from the environment" do
# Run in a child process so we don't trip up the test process with a
# sticky :trap_exit flag.
task =
Task.async(fn ->
prev = System.get_env("DRAIN_TIMEOUT_MS")
System.put_env("DRAIN_TIMEOUT_MS", "1234")
try do
assert {:ok, state} = ShutdownHandler.init([])
assert state.shutting_down == false
assert state.drain_timeout == 1234
assert Process.info(self(), :trap_exit) == {:trap_exit, true}
after
if prev do
System.put_env("DRAIN_TIMEOUT_MS", prev)
else
System.delete_env("DRAIN_TIMEOUT_MS")
end
end
end)
Task.await(task)
end
end
describe "handle_call(:shutdown, _, state) starting shutdown" do
test "marks state as shutting_down" do
# initiate_shutdown also schedules :begin_connection_drain after 15s via
# Process.send_after — too long to wait for in tests. We just verify the
# state transition; handle_info(:begin_connection_drain) is tested below.
state = %{shutting_down: false, drain_timeout: 50}
assert {:reply, :ok, new_state} =
ShutdownHandler.handle_call(:shutdown, self(), state)
assert new_state.shutting_down == true
end
end
describe "handle_info(:begin_connection_drain, state)" do
test "schedules force_shutdown after drain_timeout" do
state = %{shutting_down: true, drain_timeout: 50}
assert {:noreply, ^state} =
ShutdownHandler.handle_info(:begin_connection_drain, state)
assert_receive :force_shutdown, 200
end
end
describe "terminate/2" do
test "returns :ok for normal shutdowns without invoking drain" do
state = %{shutting_down: false, drain_timeout: 60_000}
assert :ok = ShutdownHandler.terminate(:shutdown, state)
assert :ok = ShutdownHandler.terminate({:shutdown, :any}, state)
assert :ok = ShutdownHandler.terminate(:normal, state)
end
test "returns :ok when state is already shutting_down" do
# Abnormal reason, but shutting_down is already true — no drain sleep.
state = %{shutting_down: true, drain_timeout: 60_000}
assert :ok = ShutdownHandler.terminate(:crash, state)
end
end
end

View file

@ -1,8 +1,8 @@
defmodule Aprsme.Telemetry.DatabaseMetricsTest do
# Attaches telemetry handlers at test-specific event names so each test is
# isolated, but we still keep async: false to avoid handler-name collisions
# in process-global state.
use ExUnit.Case, async: false
# Uses DataCase for a sandbox DB checkout — some metrics run live queries
# against pg_stat_* views. async: false because we mutate :aprsme :env
# in one test.
use Aprsme.DataCase, async: false
alias Aprsme.Telemetry.DatabaseMetrics
@ -56,4 +56,32 @@ defmodule Aprsme.Telemetry.DatabaseMetricsTest do
assert DatabaseMetrics.collect_pgbouncer_metrics() == :ok
end
end
describe "collect_postgres_metrics/0 outside :test env" do
test "emits database, connections, packets_table and query_stats telemetry events" do
original = Application.get_env(:aprsme, :env)
Application.put_env(:aprsme, :env, :prod)
# Attach collectors for every event emitted by the helpers.
attach_collector([:aprsme, :postgres, :database])
attach_collector([:aprsme, :postgres, :connections])
attach_collector([:aprsme, :postgres, :packets_table])
# Query stats requires pg_stat_statements which may not be loaded —
# don't assert on that one, just call the function.
try do
DatabaseMetrics.collect_postgres_metrics()
after
Application.put_env(:aprsme, :env, original)
end
assert_receive {:telemetry, [:aprsme, :postgres, :database], %{size_bytes: size}, _}, 2_000
assert is_number(size) and size > 0
assert_receive {:telemetry, [:aprsme, :postgres, :connections], conn_metrics, _}, 2_000
assert Map.has_key?(conn_metrics, :total)
assert Map.has_key?(conn_metrics, :active)
assert Map.has_key?(conn_metrics, :idle)
end
end
end

View file

@ -0,0 +1,55 @@
defmodule AprsmeWeb.Components.ErrorBoundaryTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias AprsmeWeb.Components.ErrorBoundary
test "renders inner content and default fallback" do
html =
render_component(&ErrorBoundary.error_boundary/1, %{
id: "boundary-1",
inner_block: text_slot("hello world"),
fallback: []
})
assert html =~ ~s(id="boundary-1")
assert html =~ "hello world"
assert html =~ "phx-hook=\"ErrorBoundary\""
assert html =~ "error-boundary-fallback hidden"
# The default error message is rendered when no fallback slot is provided.
assert html =~ "Something went wrong"
assert html =~ "Refresh page"
end
test "honours custom class" do
html =
render_component(&ErrorBoundary.error_boundary/1, %{
id: "boundary-2",
class: "my-custom-class",
inner_block: text_slot("content"),
fallback: []
})
assert html =~ "my-custom-class"
end
test "uses the custom fallback slot when provided" do
html =
render_component(&ErrorBoundary.error_boundary/1, %{
id: "boundary-3",
inner_block: text_slot("content"),
fallback: [
%{__slot__: :fallback, inner_block: fn _changed, _ -> "custom error!" end}
]
})
assert html =~ "custom error!"
# Default error message should not appear when a custom fallback is used.
refute html =~ "Something went wrong"
end
defp text_slot(text) do
[%{__slot__: :inner_block, inner_block: fn _changed, _ -> text end}]
end
end

View file

@ -0,0 +1,12 @@
defmodule AprsmeWeb.PageHTMLTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias AprsmeWeb.PageHTML
test "packets/1 renders the packets template" do
html = render_component(&PageHTML.packets/1, %{})
assert html =~ "Packets"
end
end

View file

@ -0,0 +1,10 @@
defmodule AprsmeWeb.AboutLiveTest do
use AprsmeWeb.ConnCase
import Phoenix.LiveViewTest
test "renders the about page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/about", on_error: :warn)
assert html =~ "aprs.me"
end
end

View file

@ -0,0 +1,59 @@
defmodule AprsmeWeb.ApiDocsLiveTest do
use AprsmeWeb.ConnCase
import Phoenix.LiveViewTest
describe "ApiDocsLive" do
test "renders the API documentation page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/api", on_error: :warn)
assert html =~ "API Documentation"
assert html =~ "/api/v1/callsign"
assert html =~ "/api/v1/weather/nearby"
end
test "update_callsign event updates the callsign input", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/api", on_error: :warn)
rendered =
lv
|> element("#test_callsign")
|> render_change(%{"callsign" => "k1abc"})
assert rendered =~ "k1abc"
end
test "submitting test_api with empty callsign shows an error", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/api", on_error: :warn)
rendered =
lv
|> form("form[phx-submit=\"test_api\"]", %{"callsign" => " "})
|> render_submit()
assert rendered =~ "Please enter a callsign"
end
test "invalid callsign format returns an error response", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/api", on_error: :warn)
# Seed the callsign via the phx-change so the form submits properly.
lv
|> element("#test_callsign")
|> render_change(%{"callsign" => "BAD!"})
rendered =
lv
|> form("form[phx-submit=\"test_api\"]", %{"callsign" => "BAD!"})
|> render_submit()
# The handler sends itself a :call_api message; after it returns the
# result assign should contain the invalid-format error JSON.
render(lv)
assert rendered =~ "Test API" or rendered =~ "Testing..."
# Wait for the async message handler to complete.
:ok = Process.sleep(50)
assert render(lv) =~ "Invalid callsign format"
end
end
end

View file

@ -0,0 +1,52 @@
defmodule AprsmeWeb.UserConfirmationInstructionsLiveTest do
use AprsmeWeb.ConnCase
import Aprsme.AccountsFixtures
import Phoenix.LiveViewTest
alias Aprsme.Accounts
alias Aprsme.Repo
describe "Resend confirmation instructions page" do
test "renders the page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/confirm", on_error: :warn)
assert html =~ "Resend confirmation instructions"
assert html =~ "Log in"
assert html =~ "Register"
end
end
describe "Resend confirmation" do
setup do
%{user: user_fixture()}
end
test "sends a new confirmation token for an unconfirmed user", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/confirm", on_error: :warn)
{:ok, conn} =
lv
|> form("#resend_confirmation_form", user: %{email: user.email})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
end
test "does not send confirmation token for unknown email", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/confirm", on_error: :warn)
{:ok, conn} =
lv
|> form("#resend_confirmation_form", user: %{email: "unknown@example.com"})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
end

View file

@ -0,0 +1,71 @@
defmodule AprsmeWeb.UserConfirmationLiveTest do
use AprsmeWeb.ConnCase
import Aprsme.AccountsFixtures
import Phoenix.LiveViewTest
alias Aprsme.Accounts
alias Aprsme.Repo
setup do
%{user: user_fixture()}
end
describe "Confirm user" do
test "renders confirmation page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token", on_error: :warn)
assert html =~ "Confirm your account"
end
test "confirms the given token once", %{conn: conn, user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}", on_error: :warn)
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully"
assert Accounts.get_user!(user.id).confirmed_at
refute conn.assigns[:current_user]
assert Repo.all(Accounts.UserToken) == []
# When not logged in a second confirmation attempt says the token is invalid
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}", on_error: :warn)
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"User confirmation link is invalid or it has expired"
end
test "does not confirm email with invalid token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token", on_error: :warn)
{:ok, conn} =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"User confirmation link is invalid or it has expired"
refute Accounts.get_user!(user.id).confirmed_at
end
end
end

View file

@ -0,0 +1,52 @@
defmodule AprsmeWeb.UserForgotPasswordLiveTest do
use AprsmeWeb.ConnCase
import Aprsme.AccountsFixtures
import Phoenix.LiveViewTest
alias Aprsme.Accounts
alias Aprsme.Repo
describe "Forgot password page" do
test "renders email page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/reset_password", on_error: :warn)
assert html =~ "Forgot your password?"
assert html =~ "Log in"
assert html =~ "Register"
end
end
describe "Reset link" do
setup do
%{user: user_fixture()}
end
test "sends a new reset password token for a known email", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password", on_error: :warn)
{:ok, conn} =
lv
|> form("#reset_password_form", user: %{"email" => user.email})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password"
end
test "does not send reset password token for an unknown email", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password", on_error: :warn)
{:ok, conn} =
lv
|> form("#reset_password_form", user: %{"email" => "unknown@example.com"})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
end

View file

@ -0,0 +1,87 @@
defmodule AprsmeWeb.UserResetPasswordLiveTest do
use AprsmeWeb.ConnCase
import Aprsme.AccountsFixtures
import Phoenix.LiveViewTest
alias Aprsme.Accounts
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
%{token: token, user: user}
end
describe "Reset password page" do
test "renders reset password with valid token", %{conn: conn, token: token} do
{:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
assert html =~ "Reset Password"
end
test "does not render reset password with invalid token", %{conn: conn} do
{:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid", on_error: :warn)
assert to == %{
flash: %{"error" => "Reset password link is invalid or it has expired."},
to: "/"
}
end
test "validate event re-renders the form", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
result =
lv
|> element("#reset_password_form")
|> render_change(user: %{"password" => "secret12", "password_confirmation" => "secret123456"})
# The validate event runs without raising and the form is re-rendered.
assert result =~ "Reset Password"
end
end
describe "Reset Password" do
test "resets password once", %{conn: conn, token: token, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
{:ok, conn} =
lv
|> form("#reset_password_form",
user: %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
)
|> render_submit()
|> follow_redirect(conn, ~p"/users/log_in")
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "does not reset password on invalid data", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
result =
lv
|> form("#reset_password_form",
user: %{
"password" => "too short",
"password_confirmation" => "does not match"
}
)
|> render_submit()
# The template shows a generic "Oops, something went wrong!" banner when
# the changeset action is :insert, rather than per-field errors.
assert result =~ "Reset Password"
assert result =~ "Oops, something went wrong!"
end
end
end

View file

@ -0,0 +1,81 @@
defmodule AprsmeWeb.WeatherLive.CallsignViewTest do
use AprsmeWeb.ConnCase
import Phoenix.LiveViewTest
alias AprsmeWeb.WeatherLive.CallsignView
describe "mount via HTTP" do
test "renders the weather view for a callsign", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather/N0CALL", on_error: :warn)
assert html =~ "N0CALL"
end
test "normalizes lowercase callsign to uppercase", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather/k1abc", on_error: :warn)
assert html =~ "K1ABC"
end
end
describe "has_weather_field?/2" do
test "returns false for missing fields" do
refute CallsignView.has_weather_field?(%{}, :temperature)
end
test "returns false for nil values" do
refute CallsignView.has_weather_field?(%{temperature: nil}, :temperature)
end
test "returns true for numeric values" do
assert CallsignView.has_weather_field?(%{temperature: 72.5}, :temperature)
end
test "returns true for non-empty string values" do
assert CallsignView.has_weather_field?(%{temperature: "72.5"}, :temperature)
end
test "returns false for empty string" do
refute CallsignView.has_weather_field?(%{temperature: ""}, :temperature)
end
end
describe "get_weather_field_zero/2" do
test "replaces N/A with 0" do
assert CallsignView.get_weather_field_zero(%{}, :temperature) == "0"
end
test "passes numeric values through" do
assert CallsignView.get_weather_field_zero(%{temperature: 72.5}, :temperature) == 72.5
end
end
describe "format_weather_value/3" do
test "returns nil for missing values" do
assert CallsignView.format_weather_value(%{}, :temperature, "en") == nil
end
test "formats numeric temperature with units" do
result = CallsignView.format_weather_value(%{temperature: 72.5}, :temperature, "en")
assert is_binary(result)
assert result =~ "72"
end
test "formats numeric wind_speed with a space separator" do
result = CallsignView.format_weather_value(%{wind_speed: 10.0}, :wind_speed, "en")
assert is_binary(result)
# Unit appears after the numeric value with a separator.
assert String.contains?(result, " ")
end
test "handles string numeric values" do
result = CallsignView.format_weather_value(%{temperature: "72.5"}, :temperature, "en")
assert is_binary(result)
end
test "handles string non-numeric values for untracked keys" do
# humidity has no entry in @weather_formatters, so binary values are
# returned as-is.
assert CallsignView.format_weather_value(%{humidity: "65"}, :humidity, "en") == "65"
end
end
end

View file

@ -0,0 +1,65 @@
defmodule Mix.Tasks.Aprs.ParseFileTest do
use ExUnit.Case, async: false
alias Mix.Tasks.Aprs.ParseFile
@tmp_dir System.tmp_dir!()
setup do
input = Path.join(@tmp_dir, "aprs_parse_file_input_#{System.unique_integer([:positive])}.txt")
output = Path.join(@tmp_dir, "aprs_parse_file_output_#{System.unique_integer([:positive])}.txt")
on_exit(fn ->
File.rm(input)
File.rm(output)
end)
%{input: input, output: output}
end
test "skips blank and comment lines and writes failed lines to the output", %{
input: input,
output: output
} do
File.write!(input, """
# a comment
N0CALL>APRS:this is not a valid packet at all
""")
# Mix tasks emit output via Mix.shell; silence it during the test.
Mix.shell(Mix.Shell.Process)
ParseFile.run([input, "--output", output])
assert File.exists?(output)
contents = File.read!(output)
# The garbage line should be written; comments and blank lines should not.
refute contents =~ "# a comment"
# The parser may or may not accept the "not a valid packet" line depending
# on its lenience — but if anything is written, it's that one line.
case String.trim(contents) do
"" -> :ok
line -> assert line == "N0CALL>APRS:this is not a valid packet at all"
end
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Processed"
end
test "errors and exits when input file is missing", %{output: output} do
Mix.shell(Mix.Shell.Process)
missing = Path.join(@tmp_dir, "does_not_exist_#{System.unique_integer([:positive])}.txt")
try do
ParseFile.run([missing, "--output", output])
flunk("expected run to exit")
catch
:exit, {:shutdown, 1} -> :ok
end
assert_received {:mix_shell, :error, [err]}
assert err =~ "input file not found"
end
end