diff --git a/test/aprsme/device_cache_test.exs b/test/aprsme/device_cache_test.exs index 58e88fd..9de18a5 100644 --- a/test/aprsme/device_cache_test.exs +++ b/test/aprsme/device_cache_test.exs @@ -72,4 +72,32 @@ defmodule Aprsme.DeviceCacheTest do assert DeviceCache.lookup_device("APSXX") == nil end end + + describe "GenServer callbacks" do + test "init/1 schedules an initial load and returns an empty state" do + assert {:ok, state} = DeviceCache.init([]) + assert state == %{initial_load_done: false} + assert_receive :initial_load, 1_500 + end + + test "handle_call(:refresh_cache, _, state) returns :ok" do + state = %{initial_load_done: true} + assert {:reply, :ok, ^state} = DeviceCache.handle_call(:refresh_cache, self(), state) + end + + test "handle_cast(:refresh_cache_async, state) keeps the state" do + state = %{initial_load_done: true} + assert {:noreply, ^state} = DeviceCache.handle_cast(:refresh_cache_async, state) + end + + test "handle_info(:initial_load, state) flips initial_load_done and schedules a periodic refresh" do + assert {:noreply, %{initial_load_done: true}} = + DeviceCache.handle_info(:initial_load, %{initial_load_done: false}) + end + + test "handle_info(:refresh_cache, state) keeps the state and schedules a periodic refresh" do + state = %{initial_load_done: true} + assert {:noreply, ^state} = DeviceCache.handle_info(:refresh_cache, state) + end + end end diff --git a/test/aprsme/device_identification_test.exs b/test/aprsme/device_identification_test.exs index 8dd7c72..96ab201 100644 --- a/test/aprsme/device_identification_test.exs +++ b/test/aprsme/device_identification_test.exs @@ -148,6 +148,59 @@ defmodule Aprsme.DeviceIdentificationTest do assert :ok == DeviceIdentification.maybe_refresh_devices() end + + test "attempts to refresh when there are no devices at all" do + Repo.delete_all(Devices) + # fetch_and_upsert_devices goes through the circuit breaker. Without a + # stub it either errors out (network failure) or returns :ok. Either + # outcome exercises the refresh_if_stale(nil, _) branch. + result = DeviceIdentification.maybe_refresh_devices() + assert result == :ok or match?({:error, _}, result) + end + end + + describe "lookup_device_by_identifier/1 wildcard and escape handling" do + test "matches with a multi-character ? wildcard pattern" do + Repo.delete_all(Devices) + + Repo.insert!(%Devices{ + identifier: "APS???", + vendor: "TestVendor", + model: "TestModel" + }) + + devices = Repo.all(Devices) + Aprsme.Cache.put(:device_cache, :all_devices, devices) + + # 'APSK21' has 6 characters, matching 'APS???'. + assert %Devices{identifier: "APS???"} = DeviceIdentification.lookup_device_by_identifier("APSK21") + # 'APS' alone is too short. + refute DeviceIdentification.lookup_device_by_identifier("APS") + # Different prefix should not match. + refute DeviceIdentification.lookup_device_by_identifier("ZZZK21") + end + + test "patterns with regex metacharacters are escaped properly" do + Repo.delete_all(Devices) + + Repo.insert!(%Devices{ + identifier: "A.B+C*", + vendor: "Regex", + model: "Edge" + }) + + devices = Repo.all(Devices) + Aprsme.Cache.put(:device_cache, :all_devices, devices) + + # The dot/plus/star should be treated as literals (no ? in pattern + # means exact match is used). + assert %Devices{identifier: "A.B+C*"} = + DeviceIdentification.lookup_device_by_identifier("A.B+C*") + + # A string that would "match" if dot/plus/star were regex metachars + # must NOT match — they're literals. + refute DeviceIdentification.lookup_device_by_identifier("AXBCC") + end end describe "lookup_device_by_identifier/1" do diff --git a/test/aprsme/signal_handler_test.exs b/test/aprsme/signal_handler_test.exs index e2c6732..b639baa 100644 --- a/test/aprsme/signal_handler_test.exs +++ b/test/aprsme/signal_handler_test.exs @@ -13,4 +13,12 @@ defmodule Aprsme.SignalHandlerTest do # We can't safely exercise the :sigterm clause here — it calls # Aprsme.ShutdownHandler.shutdown() which would actually shut the app down. end + + describe "init/1" do + test "installs a SIGTERM handler and returns an empty state" do + # :os.set_signal is process-global but calling it a second time with + # :handle is idempotent at the VM level. Safe to exercise. + assert {:ok, %{}} = SignalHandler.init([]) + end + end end diff --git a/test/aprsme_web/components/layouts_test.exs b/test/aprsme_web/components/layouts_test.exs index f07799e..4ff3f66 100644 --- a/test/aprsme_web/components/layouts_test.exs +++ b/test/aprsme_web/components/layouts_test.exs @@ -1,6 +1,8 @@ defmodule AprsmeWeb.LayoutsTest do use ExUnit.Case, async: true + import Phoenix.LiveViewTest + alias AprsmeWeb.Layouts describe "body_class/1" do @@ -20,4 +22,50 @@ defmodule AprsmeWeb.LayoutsTest do assert Layouts.body_class(%{whatever: 42}) == Layouts.body_class(%{}) end end + + describe "app/1 template" do + test "renders the site header and inner content" do + html = + render_component(&Layouts.app/1, %{ + current_user: nil, + flash: %{}, + map_page: false, + inner_content: Phoenix.HTML.raw("

inner-content-marker

") + }) + + assert html =~ "aprs.me" + assert html =~ "inner-content-marker" + # Default map_page is false, so data-map-page="false" is emitted. + assert html =~ ~s(data-map-page="false") + end + + test "emits data-map-page=\"true\" for map pages" do + html = + render_component(&Layouts.app/1, %{ + current_user: nil, + flash: %{}, + map_page: true, + inner_content: Phoenix.HTML.raw("") + }) + + assert html =~ ~s(data-map-page="true") + assert html =~ "min-h-screen" + end + end + + describe "root/1 template" do + test "renders the root layout with the inner content" do + html = + render_component(&Layouts.root/1, %{ + current_user: nil, + inner_content: Phoenix.HTML.raw("
root-layout-marker
"), + conn: %Plug.Conn{}, + page_title: nil + }) + + assert html =~ "root-layout-marker" + assert html =~ "" + end + end end diff --git a/test/aprsme_web/controllers/user_session_controller_test.exs b/test/aprsme_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..0d85eb6 --- /dev/null +++ b/test/aprsme_web/controllers/user_session_controller_test.exs @@ -0,0 +1,106 @@ +defmodule AprsmeWeb.UserSessionControllerTest do + use AprsmeWeb.ConnCase + + import Aprsme.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_aprs_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully!" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(%{}) + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully!" + end + + test "redirects to login page with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/aprsme_web/live/map_live/components_test.exs b/test/aprsme_web/live/map_live/components_test.exs index 9d380c7..447122c 100644 --- a/test/aprsme_web/live/map_live/components_test.exs +++ b/test/aprsme_web/live/map_live/components_test.exs @@ -138,5 +138,76 @@ defmodule AprsmeWeb.MapLive.ComponentsTest do assert html =~ "All Packets" assert html =~ "With Position" end + + test "renders packet cards for stations with positions" do + packet = %{ + sender: "K5GVL-10", + ssid: "10", + lat: 33.1, + lon: -96.5, + has_position: true, + comment: "Mobile station", + path: "WIDE1-1", + received_at: DateTime.utc_now() + } + + html = + render_component( + &Components.slideover_panel/1, + base_assigns(%{packets: [{"packet-1", packet}]}) + ) + + assert html =~ "K5GVL-10" + assert html =~ "Mobile station" + # The center-on-packet button is rendered for packets with position. + assert html =~ "center_on_packet" + end + + test "packet cards for stations without position hide the center button" do + packet = %{ + sender: "K5GVL", + ssid: "0", + lat: nil, + lon: nil, + has_position: false, + comment: nil, + path: nil, + received_at: DateTime.utc_now() + } + + html = + render_component( + &Components.slideover_panel/1, + base_assigns(%{packets: [{"packet-2", packet}]}) + ) + + assert html =~ "K5GVL" + assert html =~ "direct" + refute html =~ "center_on_packet" + end + + test "shows the last-seen timestamp for a tracked callsign" do + latest = %{received_at: DateTime.add(DateTime.utc_now(), -60, :second)} + + html = + render_component( + &Components.slideover_panel/1, + base_assigns(%{ + tracked_callsign: "K5ABC", + tracked_callsign_latest_packet: latest + }) + ) + + assert html =~ "Last seen" + end + end + + describe "map_styles/1" do + test "renders a