test: expand coverage for Layouts, DeviceCache, Components and others

This commit is contained in:
Graham McIntire 2026-04-23 16:35:53 -05:00
parent 006f9ab98b
commit 46e71019de
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 350 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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("<p>inner-content-marker</p>")
})
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("<main>root-layout-marker</main>"),
conn: %Plug.Conn{},
page_title: nil
})
assert html =~ "root-layout-marker"
assert html =~ "<html"
assert html =~ "</html>"
end
end
end

View file

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

View file

@ -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 <style> block" do
html = render_component(&Components.map_styles/1, %{})
assert html =~ "<style>"
assert html =~ "#aprs-map"
assert html =~ ".slideover-panel"
end
end
end

View file

@ -226,4 +226,40 @@ defmodule AprsmeWeb.MapLive.PacketProcessorTest do
assert Map.has_key?(result.assigns.visible_packets, "OTHER-1")
end
end
describe "process_packet_for_display/2" do
test "updates last_update_at and returns {:noreply, socket}" do
socket = build_socket()
packet = build_packet()
before = DateTime.utc_now()
{:noreply, new_socket} = PacketProcessor.process_packet_for_display(packet, socket)
assert DateTime.compare(new_socket.assigns.last_update_at, before) in [:gt, :eq]
# Marker should have been added to visible_packets.
assert Map.has_key?(new_socket.assigns.visible_packets, "K5GVL-10")
end
test "leaves state mostly intact for out-of-bounds packets" do
socket = build_socket()
packet = build_packet(%{lat: 50.0, lon: -50.0})
{:noreply, new_socket} = PacketProcessor.process_packet_for_display(packet, socket)
refute Map.has_key?(new_socket.assigns.visible_packets, "K5GVL-10")
assert %DateTime{} = new_socket.assigns.last_update_at
end
end
describe "handle_valid_postgres_packet/4 at marker zoom" do
test "adds marker to visible_packets and returns a socket" do
socket = build_socket(%{map_zoom: 12})
packet = build_packet()
new_socket =
PacketProcessor.handle_valid_postgres_packet(packet, packet.lat, packet.lon, socket)
assert Map.has_key?(new_socket.assigns.visible_packets, "K5GVL-10")
end
end
end