diff --git a/lib/aprsme/device_identification.ex b/lib/aprsme/device_identification.ex index 6fb6eca..17bd00c 100644 --- a/lib/aprsme/device_identification.ex +++ b/lib/aprsme/device_identification.ex @@ -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 diff --git a/test/aprsme/accounts_test.exs b/test/aprsme/accounts_test.exs new file mode 100644 index 0000000..f53076a --- /dev/null +++ b/test/aprsme/accounts_test.exs @@ -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 diff --git a/test/aprsme/device_identification_test.exs b/test/aprsme/device_identification_test.exs index c682b55..8dd7c72 100644 --- a/test/aprsme/device_identification_test.exs +++ b/test/aprsme/device_identification_test.exs @@ -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 diff --git a/test/aprsme_web/components/core_components_test.exs b/test/aprsme_web/components/core_components_test.exs new file mode 100644 index 0000000..9305ec8 --- /dev/null +++ b/test/aprsme_web/components/core_components_test.exs @@ -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 =~ " with the default classes" do + html = render_component(&CoreComponents.button/1, %{inner_block: text_slot("Save")}) + assert html =~ " wrapping the inner block" do + html = + render_component(&CoreComponents.label/1, %{ + for: "name", + inner_block: text_slot("Name") + }) + + assert html =~ " "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 diff --git a/test/aprsme_web/components/layouts_test.exs b/test/aprsme_web/components/layouts_test.exs new file mode 100644 index 0000000..f07799e --- /dev/null +++ b/test/aprsme_web/components/layouts_test.exs @@ -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 diff --git a/test/aprsme_web/controllers/error_html_test.exs b/test/aprsme_web/controllers/error_html_test.exs index bb864c1..e7fe1f5 100644 --- a/test/aprsme_web/controllers/error_html_test.exs +++ b/test/aprsme_web/controllers/error_html_test.exs @@ -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 diff --git a/test/aprsme_web/live/map_live/components_test.exs b/test/aprsme_web/live/map_live/components_test.exs new file mode 100644 index 0000000..9d380c7 --- /dev/null +++ b/test/aprsme_web/live/map_live/components_test.exs @@ -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