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:
parent
6edc401168
commit
fa893dd12e
7 changed files with 499 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
111
test/aprsme/accounts_test.exs
Normal file
111
test/aprsme/accounts_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
172
test/aprsme_web/components/core_components_test.exs
Normal file
172
test/aprsme_web/components/core_components_test.exs
Normal 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
|
||||
23
test/aprsme_web/components/layouts_test.exs
Normal file
23
test/aprsme_web/components/layouts_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
142
test/aprsme_web/live/map_live/components_test.exs
Normal file
142
test/aprsme_web/live/map_live/components_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue