test: cover ErrorHTML, Layouts, MapLive.Components, CoreComponents, Accounts

- ErrorHTML: explicit 400 path + 401/403/999 delegation.
- Layouts.body_class/1 returns the expected light/dark theme classes.
- MapLive.Components: map_container + slideover_panel exercising all
  connection_status variants, loading state, tracked_callsign clear
  button, and view-mode radios.
- CoreComponents: icon (found + fallback), button, label, error,
  flash (info + error + close=false), checkbox input, JS composers.
- Accounts: get_user_by_email{_and_password}, get_user!, register_user
  happy-path + error paths, change_* changesets, session-token
  round-trip.

Refactor: DeviceIdentification.maybe_refresh_devices splits its nested
logic into fetch_most_recent_device/0, to_datetime/1, and
refresh_if_stale/2 — each a multi-clause function matching on shape.

Coverage 68.28 → 69.56%.
This commit is contained in:
Graham McIntire 2026-04-23 16:16:30 -05:00
parent 6edc401168
commit fa893dd12e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 499 additions and 17 deletions

View file

@ -114,25 +114,29 @@ defmodule Aprsme.DeviceIdentification do
@week_seconds 7 * 24 * 60 * 60
def maybe_refresh_devices do
last =
try do
Repo.one(from d in Devices, order_by: [desc: d.updated_at], limit: 1)
rescue
_ -> nil
end
last = fetch_most_recent_device()
refresh_if_stale(last, to_datetime(last && last.updated_at))
end
last_time =
case last && last.updated_at do
%NaiveDateTime{} = naive -> DateTime.from_naive!(naive, "Etc/UTC")
%DateTime{} = dt -> dt
_ -> nil
end
defp fetch_most_recent_device do
Repo.one(from d in Devices, order_by: [desc: d.updated_at], limit: 1)
rescue
_ -> nil
end
if last == nil or (last_time && DateTime.diff(DateTime.utc_now(), last_time) > @week_seconds) do
fetch_and_upsert_devices()
else
:ok
end
defp to_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC")
defp to_datetime(%DateTime{} = dt), do: dt
defp to_datetime(_), do: nil
# No devices at all — always refresh.
defp refresh_if_stale(nil, _last_time), do: fetch_and_upsert_devices()
# Stored rows but unknown timestamp — treat as stale.
defp refresh_if_stale(_last, nil), do: fetch_and_upsert_devices()
defp refresh_if_stale(_last, last_time) do
age_seconds = DateTime.diff(DateTime.utc_now(), last_time)
if age_seconds > @week_seconds, do: fetch_and_upsert_devices(), else: :ok
end
# The Devices schema uses :naive_datetime for its timestamps, so insert_all

View file

@ -0,0 +1,111 @@
defmodule Aprsme.AccountsTest do
use Aprsme.DataCase, async: true
import Aprsme.AccountsFixtures
alias Aprsme.Accounts
alias Aprsme.Accounts.User
describe "get_user_by_email/1" do
test "returns the user when email matches" do
user = user_fixture()
assert Accounts.get_user_by_email(user.email).id == user.id
end
test "returns nil for unknown email" do
assert Accounts.get_user_by_email("nobody@example.com") == nil
end
end
describe "get_user_by_email_and_password/2" do
test "returns the user for the right password" do
user = user_fixture()
assert Accounts.get_user_by_email_and_password(user.email, valid_user_password()).id == user.id
end
test "returns nil for wrong password" do
user = user_fixture()
assert Accounts.get_user_by_email_and_password(user.email, "wrong-password-123") == nil
end
test "returns nil when user doesn't exist" do
assert Accounts.get_user_by_email_and_password("nobody@example.com", "whatever") == nil
end
end
describe "get_user!/1" do
test "returns the user by id" do
user = user_fixture()
assert Accounts.get_user!(user.id).id == user.id
end
test "raises for unknown id" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!(-1)
end
end
end
describe "register_user/1" do
test "inserts a new user with valid attrs" do
attrs = valid_user_attributes()
assert {:ok, %User{} = user} = Accounts.register_user(attrs)
assert user.email == attrs.email
end
test "returns an error changeset for invalid attrs" do
assert {:error, %Ecto.Changeset{}} = Accounts.register_user(%{})
end
test "returns an error for duplicate email" do
user = user_fixture()
assert {:error, changeset} = Accounts.register_user(valid_user_attributes(%{email: user.email}))
assert "has already been taken" in errors_on(changeset).email
end
end
describe "change_user_registration/2" do
test "returns a changeset with casted attrs" do
changeset = Accounts.change_user_registration(%User{}, valid_user_attributes())
assert %Ecto.Changeset{valid?: true} = changeset
end
end
describe "change_user_email/2" do
test "returns an email-focused changeset" do
user = user_fixture()
changeset = Accounts.change_user_email(user, %{"email" => "new@example.com"})
assert %Ecto.Changeset{} = changeset
end
end
describe "change_user_password/2" do
test "returns a password-focused changeset" do
user = user_fixture()
changeset = Accounts.change_user_password(user, %{"password" => "new valid password"})
assert %Ecto.Changeset{} = changeset
end
end
describe "change_user_callsign/2" do
test "returns a callsign-focused changeset" do
user = user_fixture()
changeset = Accounts.change_user_callsign(user, %{"callsign" => "K5XYZ"})
assert %Ecto.Changeset{} = changeset
end
end
describe "session tokens" do
test "generate + get + delete round-trip" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert is_binary(token)
fetched = Accounts.get_user_by_session_token(token)
assert fetched.id == user.id
assert :ok = Accounts.delete_user_session_token(token)
assert Accounts.get_user_by_session_token(token) == nil
end
end
end

View file

@ -133,6 +133,23 @@ defmodule Aprsme.DeviceIdentificationTest do
end
end
describe "maybe_refresh_devices/0" do
test "returns :ok when the most recent device is fresh (< 1 week old)" do
Repo.delete_all(Devices)
now = NaiveDateTime.utc_now(:second)
Repo.insert!(%Devices{
identifier: "FRESHROW",
vendor: "v",
model: "m",
inserted_at: now,
updated_at: now
})
assert :ok == DeviceIdentification.maybe_refresh_devices()
end
end
describe "lookup_device_by_identifier/1" do
test "matches APSK21 to APS??? pattern" do
# Seed the devices table from the JSON

View file

@ -0,0 +1,172 @@
defmodule AprsmeWeb.CoreComponentsTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias AprsmeWeb.CoreComponents
alias Phoenix.LiveView.JS
describe "icon/1" do
test "renders an inline SVG for a valid heroicon" do
html = render_component(&CoreComponents.icon/1, name: "arrow-left")
# Either an SVG or the "icon not found" comment is returned.
assert html =~ "<svg" or html =~ "icon not found"
end
test "renders a 'not found' comment for unknown icons" do
html = render_component(&CoreComponents.icon/1, name: "nope-not-a-real-icon")
assert html =~ "icon not found: nope-not-a-real-icon"
end
test "falls back to outline=true by default" do
# With a valid heroicon, the outline variant is selected.
html = render_component(&CoreComponents.icon/1, name: "x-mark")
assert is_binary(html)
end
test "honours custom class" do
html = render_component(&CoreComponents.icon/1, name: "x-mark", class: "my-special-class")
# Either inserted into the SVG tag or absent for missing icons.
assert is_binary(html)
end
end
describe "button/1" do
test "renders a native <button> with the default classes" do
html = render_component(&CoreComponents.button/1, %{inner_block: text_slot("Save")})
assert html =~ "<button"
assert html =~ "Save"
end
test "passes through custom class" do
html =
render_component(&CoreComponents.button/1, %{
inner_block: text_slot("Click"),
class: "custom-class"
})
assert html =~ "custom-class"
end
end
describe "label/1" do
test "renders a <label> wrapping the inner block" do
html =
render_component(&CoreComponents.label/1, %{
for: "name",
inner_block: text_slot("Name")
})
assert html =~ "<label"
assert html =~ ~s(for="name")
assert html =~ "Name"
end
end
describe "error/1" do
test "renders the error message and the default heroicon marker" do
html = render_component(&CoreComponents.error/1, %{inner_block: text_slot("is required")})
assert html =~ "is required"
end
end
describe "header/1" do
test "renders the site header with navigation links" do
html = render_component(&CoreComponents.header/1, %{})
# This is the site header component (not a generic header), so it
# always emits the nav links regardless of the assigns.
assert html =~ "Home"
assert html =~ "About"
assert html =~ "aprs.me"
end
end
describe "flash/1" do
test "renders an info flash with title and message" do
html =
render_component(&CoreComponents.flash/1, %{
id: "flash-1",
kind: :info,
title: "Saved",
flash: %{"info" => "Your changes have been saved"},
close: true,
autoshow: false
})
assert html =~ "Saved"
assert html =~ "Your changes have been saved"
assert html =~ ~s(id="flash-1")
assert html =~ "bg-emerald-50"
end
test "renders an error flash with rose styling" do
html =
render_component(&CoreComponents.flash/1, %{
id: "flash-err",
kind: :error,
title: nil,
flash: %{"error" => "Something went wrong"},
close: true,
autoshow: false
})
assert html =~ "Something went wrong"
assert html =~ "bg-rose-50"
end
test "omits the close button when close: false" do
html =
render_component(&CoreComponents.flash/1, %{
id: "flash-2",
kind: :info,
title: nil,
flash: %{"info" => "hi"},
close: false,
autoshow: false
})
refute html =~ ~s(aria-label="close")
end
end
describe "input/1 checkbox" do
test "renders a checkbox with a hidden false companion input" do
html =
render_component(&CoreComponents.input/1, %{
id: "enabled",
name: "enabled",
type: "checkbox",
value: "true",
errors: []
})
assert html =~ ~s(type="hidden")
assert html =~ ~s(type="checkbox")
# Checkbox is pre-checked when value matches "true".
assert html =~ "checked"
end
end
describe "show/1 and hide/1" do
test "show/1 composes onto a JS struct" do
result = CoreComponents.show(%JS{}, "#foo")
assert %JS{} = result
end
test "hide/1 composes onto a JS struct" do
result = CoreComponents.hide(%JS{}, "#foo")
assert %JS{} = result
end
end
describe "show_modal/2 and hide_modal/2" do
test "show_modal returns a JS struct" do
assert %JS{} = CoreComponents.show_modal("my-modal")
end
end
# Helper: build a LiveView inner_block slot that just emits text.
defp text_slot(text) do
[%{__slot__: :inner_block, inner_block: fn _changed, _ -> text end}]
end
end

View file

@ -0,0 +1,23 @@
defmodule AprsmeWeb.LayoutsTest do
use ExUnit.Case, async: true
alias AprsmeWeb.Layouts
describe "body_class/1" do
test "returns the default light/dark-aware class list" do
result = Layouts.body_class(%{})
assert is_list(result)
[classes] = result
assert classes =~ "bg-white"
assert classes =~ "dark:bg-gray-900"
assert classes =~ "text-gray-900"
assert classes =~ "dark:text-white"
assert classes =~ "antialiased"
end
test "ignores assigns content" do
assert Layouts.body_class(%{theme: "dark"}) == Layouts.body_class(%{})
assert Layouts.body_class(%{whatever: 42}) == Layouts.body_class(%{})
end
end
end

View file

@ -11,4 +11,17 @@ defmodule AprsmeWeb.ErrorHTMLTest do
test "renders 500.html" do
assert render_to_string(AprsmeWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
end
test "renders explicit 'Bad Request' for 400.html" do
assert AprsmeWeb.ErrorHTML.render("400.html", %{}) == "Bad Request"
end
test "delegates 403/401 to Phoenix default template messages" do
assert AprsmeWeb.ErrorHTML.render("403.html", %{}) == "Forbidden"
assert AprsmeWeb.ErrorHTML.render("401.html", %{}) == "Unauthorized"
end
test "unknown template falls through to the delegated default" do
assert is_binary(AprsmeWeb.ErrorHTML.render("999.html", %{}))
end
end

View file

@ -0,0 +1,142 @@
defmodule AprsmeWeb.MapLive.ComponentsTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias AprsmeWeb.MapLive.Components
describe "map_container/1" do
test "renders aprs-map div with encoded center and zoom" do
html =
render_component(&Components.map_container/1,
map_center: %{lat: 40.0, lng: -100.0},
map_zoom: 5,
slideover_open: false
)
assert html =~ ~s(id="aprs-map")
assert html =~ ~s(phx-hook="APRSMap")
assert html =~ "data-zoom=\"5\""
assert html =~ "40.0"
assert html =~ "-100.0"
end
test "adds slideover-open class when panel is open" do
html =
render_component(&Components.map_container/1,
map_center: %{lat: 0.0, lng: 0.0},
map_zoom: 5,
slideover_open: true
)
assert html =~ "slideover-open"
end
test "adds slideover-closed class when panel is closed" do
html =
render_component(&Components.map_container/1,
map_center: %{lat: 0.0, lng: 0.0},
map_zoom: 5,
slideover_open: false
)
assert html =~ "slideover-closed"
end
end
describe "slideover_panel/1" do
defp base_assigns(overrides \\ %{}) do
Enum.into(overrides, %{
slideover_open: true,
loading: false,
connection_status: "connected",
packets: [],
show_all_packets: true,
tracked_callsign: "",
tracked_callsign_latest_packet: nil
})
end
test "renders header + content when open" do
html = render_component(&Components.slideover_panel/1, base_assigns())
assert html =~ "APRS Packets"
assert html =~ "slideover-panel"
end
test "shows the 'connected' indicator text for a connected status" do
html =
render_component(
&Components.slideover_panel/1,
base_assigns(%{connection_status: "connected"})
)
assert html =~ "Connected to APRS-IS"
end
test "shows 'connecting' indicator text" do
html =
render_component(
&Components.slideover_panel/1,
base_assigns(%{connection_status: "connecting"})
)
assert html =~ "Connecting to APRS-IS"
end
test "shows 'disconnected' indicator text" do
html =
render_component(
&Components.slideover_panel/1,
base_assigns(%{connection_status: "disconnected"})
)
assert html =~ "Disconnected from APRS-IS"
end
test "shows 'Initializing...' for pending/unknown status" do
html =
render_component(
&Components.slideover_panel/1,
base_assigns(%{connection_status: "pending"})
)
assert html =~ "Initializing"
end
test "shows the loading spinner when loading" do
html =
render_component(
&Components.slideover_panel/1,
base_assigns(%{loading: true})
)
assert html =~ "Loading packets"
# Spinner SVG has an animate-spin class.
assert html =~ "animate-spin"
end
test "shows a clear button only when a callsign is tracked" do
with_tracked =
render_component(
&Components.slideover_panel/1,
base_assigns(%{tracked_callsign: "K5ABC"})
)
assert with_tracked =~ "clear_callsign_filter"
without_tracked =
render_component(
&Components.slideover_panel/1,
base_assigns(%{tracked_callsign: ""})
)
refute without_tracked =~ "clear_callsign_filter"
end
test "renders the 'All' and 'With Position' radio options" do
html = render_component(&Components.slideover_panel/1, base_assigns())
assert html =~ "All Packets"
assert html =~ "With Position"
end
end
end