diff --git a/test/aprsme/accounts/user_test.exs b/test/aprsme/accounts/user_test.exs index ea5b6d2..3f77eb9 100644 --- a/test/aprsme/accounts/user_test.exs +++ b/test/aprsme/accounts/user_test.exs @@ -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 diff --git a/test/aprsme/packet_replay_test.exs b/test/aprsme/packet_replay_test.exs index b30c8c8..db9021e 100644 --- a/test/aprsme/packet_replay_test.exs +++ b/test/aprsme/packet_replay_test.exs @@ -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 diff --git a/test/aprsme/shutdown_handler_test.exs b/test/aprsme/shutdown_handler_test.exs index 41647e6..1dff6e9 100644 --- a/test/aprsme/shutdown_handler_test.exs +++ b/test/aprsme/shutdown_handler_test.exs @@ -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 diff --git a/test/aprsme/telemetry/database_metrics_test.exs b/test/aprsme/telemetry/database_metrics_test.exs index 8de3223..52f4b81 100644 --- a/test/aprsme/telemetry/database_metrics_test.exs +++ b/test/aprsme/telemetry/database_metrics_test.exs @@ -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 diff --git a/test/aprsme_web/components/error_boundary_test.exs b/test/aprsme_web/components/error_boundary_test.exs new file mode 100644 index 0000000..da32cf7 --- /dev/null +++ b/test/aprsme_web/components/error_boundary_test.exs @@ -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 diff --git a/test/aprsme_web/controllers/page_html_test.exs b/test/aprsme_web/controllers/page_html_test.exs new file mode 100644 index 0000000..253e11c --- /dev/null +++ b/test/aprsme_web/controllers/page_html_test.exs @@ -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 diff --git a/test/aprsme_web/live/about_live_test.exs b/test/aprsme_web/live/about_live_test.exs new file mode 100644 index 0000000..a6843ef --- /dev/null +++ b/test/aprsme_web/live/about_live_test.exs @@ -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 diff --git a/test/aprsme_web/live/api_docs_live_test.exs b/test/aprsme_web/live/api_docs_live_test.exs new file mode 100644 index 0000000..fabd50f --- /dev/null +++ b/test/aprsme_web/live/api_docs_live_test.exs @@ -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 diff --git a/test/aprsme_web/live/user_confirmation_instructions_live_test.exs b/test/aprsme_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..1d6febd --- /dev/null +++ b/test/aprsme_web/live/user_confirmation_instructions_live_test.exs @@ -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 diff --git a/test/aprsme_web/live/user_confirmation_live_test.exs b/test/aprsme_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..9a9268a --- /dev/null +++ b/test/aprsme_web/live/user_confirmation_live_test.exs @@ -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 diff --git a/test/aprsme_web/live/user_forgot_password_live_test.exs b/test/aprsme_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..44437df --- /dev/null +++ b/test/aprsme_web/live/user_forgot_password_live_test.exs @@ -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 diff --git a/test/aprsme_web/live/user_reset_password_live_test.exs b/test/aprsme_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..50e890e --- /dev/null +++ b/test/aprsme_web/live/user_reset_password_live_test.exs @@ -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 diff --git a/test/aprsme_web/live/weather_live/callsign_view_test.exs b/test/aprsme_web/live/weather_live/callsign_view_test.exs new file mode 100644 index 0000000..f03188d --- /dev/null +++ b/test/aprsme_web/live/weather_live/callsign_view_test.exs @@ -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 diff --git a/test/mix/tasks/aprs.parse_file_test.exs b/test/mix/tasks/aprs.parse_file_test.exs new file mode 100644 index 0000000..d09ec8b --- /dev/null +++ b/test/mix/tasks/aprs.parse_file_test.exs @@ -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