test: cover auth LiveViews, PacketReplay, ShutdownHandler and more
This commit is contained in:
parent
3ab167777e
commit
41731257e6
14 changed files with 865 additions and 4 deletions
|
|
@ -93,4 +93,115 @@ defmodule Aprsme.Accounts.UserTest do
|
|||
assert get_change(changeset, :callsign) == "W2XYZ"
|
||||
end
|
||||
end
|
||||
|
||||
describe "email_changeset/2" do
|
||||
test "requires email to change" do
|
||||
user = %User{email: "a@b.com"}
|
||||
changeset = User.email_changeset(user, %{email: "a@b.com"})
|
||||
errors = errors_on(changeset)
|
||||
assert Map.has_key?(errors, :email)
|
||||
assert "did not change" in errors.email
|
||||
end
|
||||
|
||||
test "validates format of the new email" do
|
||||
user = %User{email: "a@b.com"}
|
||||
changeset = User.email_changeset(user, %{email: "bad email"})
|
||||
errors = errors_on(changeset)
|
||||
assert "must have the @ sign and no spaces" in errors.email
|
||||
end
|
||||
end
|
||||
|
||||
describe "password_changeset/2" do
|
||||
test "requires matching confirmation" do
|
||||
user = %User{}
|
||||
|
||||
changeset =
|
||||
User.password_changeset(user, %{
|
||||
"password" => "abcdefghijkl",
|
||||
"password_confirmation" => "different"
|
||||
})
|
||||
|
||||
errors = errors_on(changeset)
|
||||
assert "does not match password" in errors.password_confirmation
|
||||
end
|
||||
|
||||
test "hashes password when valid" do
|
||||
user = %User{}
|
||||
|
||||
changeset =
|
||||
User.password_changeset(user, %{
|
||||
"password" => "abcdefghijkl",
|
||||
"password_confirmation" => "abcdefghijkl"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert is_binary(get_change(changeset, :hashed_password))
|
||||
# Plaintext password is dropped after hashing.
|
||||
refute get_change(changeset, :password)
|
||||
end
|
||||
end
|
||||
|
||||
describe "confirm_changeset/1" do
|
||||
test "sets confirmed_at to the current time truncated to seconds" do
|
||||
changeset = User.confirm_changeset(%User{})
|
||||
confirmed_at = get_change(changeset, :confirmed_at)
|
||||
assert %NaiveDateTime{} = confirmed_at
|
||||
# No microseconds — truncated.
|
||||
assert confirmed_at.microsecond == {0, 0}
|
||||
end
|
||||
end
|
||||
|
||||
describe "valid_password?/2" do
|
||||
test "returns false when the user has no hashed password" do
|
||||
refute User.valid_password?(%User{hashed_password: nil}, "any")
|
||||
end
|
||||
|
||||
test "returns false when the provided password is empty" do
|
||||
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
|
||||
refute User.valid_password?(user, "")
|
||||
end
|
||||
|
||||
test "returns true for the correct password" do
|
||||
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
|
||||
assert User.valid_password?(user, "hello world!")
|
||||
end
|
||||
|
||||
test "returns false for the wrong password" do
|
||||
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
|
||||
refute User.valid_password?(user, "wrong")
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_current_password/2" do
|
||||
test "adds an error when the password is wrong" do
|
||||
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
|
||||
changeset = Ecto.Changeset.change(user)
|
||||
changeset = User.validate_current_password(changeset, "wrong")
|
||||
errors = errors_on(changeset)
|
||||
assert "is not valid" in errors.current_password
|
||||
end
|
||||
|
||||
test "does not add an error when the password is correct" do
|
||||
user = %User{hashed_password: Bcrypt.hash_pwd_salt("hello world!")}
|
||||
changeset = Ecto.Changeset.change(user)
|
||||
changeset = User.validate_current_password(changeset, "hello world!")
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
describe "Inspect protocol" do
|
||||
test "redacts the hashed and plaintext passwords" do
|
||||
user = %User{
|
||||
email: "a@b.com",
|
||||
hashed_password: "super-secret-hash",
|
||||
password: "super-secret"
|
||||
}
|
||||
|
||||
output = inspect(user)
|
||||
refute output =~ "super-secret-hash"
|
||||
refute output =~ "super-secret"
|
||||
# The email is non-redacted, so it should still appear.
|
||||
assert output =~ "a@b.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -155,4 +155,115 @@ defmodule Aprsme.PacketReplayTest do
|
|||
assert {:ok, _state} = result2
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_call/3" do
|
||||
setup do
|
||||
# Flush any messages lingering from init send_self.
|
||||
{:ok, state} = PacketReplay.init(user_id: "caller", bounds: [0, 0, 1, 1])
|
||||
|
||||
receive do
|
||||
:start_replay -> :ok
|
||||
after
|
||||
0 -> :ok
|
||||
end
|
||||
|
||||
{:ok, state: state}
|
||||
end
|
||||
|
||||
test "pause cancels any pending timer and marks state paused", %{state: state} do
|
||||
timer_ref = Process.send_after(self(), :noop, 60_000)
|
||||
state = %{state | replay_timer: timer_ref}
|
||||
|
||||
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:pause, self(), state)
|
||||
assert new_state.paused
|
||||
assert is_nil(new_state.replay_timer)
|
||||
end
|
||||
|
||||
test "resume from paused sends continue message and flips flag", %{state: state} do
|
||||
state = %{state | paused: true}
|
||||
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:resume, self(), state)
|
||||
refute new_state.paused
|
||||
assert_received {:continue_replay}
|
||||
end
|
||||
|
||||
test "resume when already running is a no-op", %{state: state} do
|
||||
assert {:reply, :ok, ^state} = PacketReplay.handle_call(:resume, self(), state)
|
||||
end
|
||||
|
||||
test "set_speed updates the replay speed", %{state: state} do
|
||||
assert {:reply, :ok, %{replay_speed: 7.5}} =
|
||||
PacketReplay.handle_call({:set_speed, 7.5}, self(), state)
|
||||
end
|
||||
|
||||
test "update_filters merges new keys into state", %{state: state} do
|
||||
filters = [callsign: "K1ABC", replay_speed: 3.0]
|
||||
|
||||
assert {:reply, :ok, new_state} =
|
||||
PacketReplay.handle_call({:update_filters, filters}, self(), state)
|
||||
|
||||
assert new_state.callsign == "K1ABC"
|
||||
assert new_state.replay_speed == 3.0
|
||||
assert new_state.packets_sent == 0
|
||||
end
|
||||
|
||||
test "update_filters rejects invalid bounds and keeps the old bounds", %{state: state} do
|
||||
filters = [bounds: "not a list"]
|
||||
|
||||
assert {:reply, :ok, new_state} =
|
||||
PacketReplay.handle_call({:update_filters, filters}, self(), state)
|
||||
|
||||
# Should have reverted to the original bounds.
|
||||
assert new_state.bounds == state.bounds
|
||||
end
|
||||
|
||||
test "get_info returns a public view of state", %{state: state} do
|
||||
assert {:reply, info, ^state} = PacketReplay.handle_call(:get_info, self(), state)
|
||||
assert info.user_id == "caller"
|
||||
assert info.bounds == [0, 0, 1, 1]
|
||||
assert info.paused == false
|
||||
assert info.packets_sent == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_info/2 paused path" do
|
||||
test "pause-time :send_packet reschedules via timer rather than broadcasting" do
|
||||
{:ok, state} = PacketReplay.init(user_id: "paused", bounds: [0, 0, 1, 1])
|
||||
|
||||
receive do
|
||||
:start_replay -> :ok
|
||||
after
|
||||
0 -> :ok
|
||||
end
|
||||
|
||||
state = %{state | paused: true}
|
||||
packet = %{received_at: DateTime.utc_now()}
|
||||
stream = Stream.repeatedly(fn -> {0.0, packet} end)
|
||||
|
||||
assert {:noreply, new_state} =
|
||||
PacketReplay.handle_info({:send_packet, packet, stream}, state)
|
||||
|
||||
# A new timer ref should be set for rescheduling.
|
||||
assert is_reference(new_state.replay_timer)
|
||||
Process.cancel_timer(new_state.replay_timer)
|
||||
end
|
||||
end
|
||||
|
||||
describe "terminate/2" do
|
||||
test "cancels pending timer and returns :ok" do
|
||||
{:ok, state} = PacketReplay.init(user_id: "terminated", bounds: [0, 0, 1, 1])
|
||||
|
||||
receive do
|
||||
:start_replay -> :ok
|
||||
after
|
||||
0 -> :ok
|
||||
end
|
||||
|
||||
timer_ref = Process.send_after(self(), :noop, 60_000)
|
||||
state = %{state | replay_timer: timer_ref}
|
||||
|
||||
assert :ok = PacketReplay.terminate(:shutdown, state)
|
||||
# Timer should have been cancelled.
|
||||
assert Process.read_timer(timer_ref) == false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,4 +47,71 @@ defmodule Aprsme.ShutdownHandlerTest do
|
|||
assert is_boolean(ShutdownHandler.shutting_down?())
|
||||
end
|
||||
end
|
||||
|
||||
describe "init/1" do
|
||||
test "traps exits and reads drain timeout from the environment" do
|
||||
# Run in a child process so we don't trip up the test process with a
|
||||
# sticky :trap_exit flag.
|
||||
task =
|
||||
Task.async(fn ->
|
||||
prev = System.get_env("DRAIN_TIMEOUT_MS")
|
||||
System.put_env("DRAIN_TIMEOUT_MS", "1234")
|
||||
|
||||
try do
|
||||
assert {:ok, state} = ShutdownHandler.init([])
|
||||
assert state.shutting_down == false
|
||||
assert state.drain_timeout == 1234
|
||||
assert Process.info(self(), :trap_exit) == {:trap_exit, true}
|
||||
after
|
||||
if prev do
|
||||
System.put_env("DRAIN_TIMEOUT_MS", prev)
|
||||
else
|
||||
System.delete_env("DRAIN_TIMEOUT_MS")
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
Task.await(task)
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_call(:shutdown, _, state) starting shutdown" do
|
||||
test "marks state as shutting_down" do
|
||||
# initiate_shutdown also schedules :begin_connection_drain after 15s via
|
||||
# Process.send_after — too long to wait for in tests. We just verify the
|
||||
# state transition; handle_info(:begin_connection_drain) is tested below.
|
||||
state = %{shutting_down: false, drain_timeout: 50}
|
||||
|
||||
assert {:reply, :ok, new_state} =
|
||||
ShutdownHandler.handle_call(:shutdown, self(), state)
|
||||
|
||||
assert new_state.shutting_down == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_info(:begin_connection_drain, state)" do
|
||||
test "schedules force_shutdown after drain_timeout" do
|
||||
state = %{shutting_down: true, drain_timeout: 50}
|
||||
|
||||
assert {:noreply, ^state} =
|
||||
ShutdownHandler.handle_info(:begin_connection_drain, state)
|
||||
|
||||
assert_receive :force_shutdown, 200
|
||||
end
|
||||
end
|
||||
|
||||
describe "terminate/2" do
|
||||
test "returns :ok for normal shutdowns without invoking drain" do
|
||||
state = %{shutting_down: false, drain_timeout: 60_000}
|
||||
assert :ok = ShutdownHandler.terminate(:shutdown, state)
|
||||
assert :ok = ShutdownHandler.terminate({:shutdown, :any}, state)
|
||||
assert :ok = ShutdownHandler.terminate(:normal, state)
|
||||
end
|
||||
|
||||
test "returns :ok when state is already shutting_down" do
|
||||
# Abnormal reason, but shutting_down is already true — no drain sleep.
|
||||
state = %{shutting_down: true, drain_timeout: 60_000}
|
||||
assert :ok = ShutdownHandler.terminate(:crash, state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
defmodule Aprsme.Telemetry.DatabaseMetricsTest do
|
||||
# Attaches telemetry handlers at test-specific event names so each test is
|
||||
# isolated, but we still keep async: false to avoid handler-name collisions
|
||||
# in process-global state.
|
||||
use ExUnit.Case, async: false
|
||||
# Uses DataCase for a sandbox DB checkout — some metrics run live queries
|
||||
# against pg_stat_* views. async: false because we mutate :aprsme :env
|
||||
# in one test.
|
||||
use Aprsme.DataCase, async: false
|
||||
|
||||
alias Aprsme.Telemetry.DatabaseMetrics
|
||||
|
||||
|
|
@ -56,4 +56,32 @@ defmodule Aprsme.Telemetry.DatabaseMetricsTest do
|
|||
assert DatabaseMetrics.collect_pgbouncer_metrics() == :ok
|
||||
end
|
||||
end
|
||||
|
||||
describe "collect_postgres_metrics/0 outside :test env" do
|
||||
test "emits database, connections, packets_table and query_stats telemetry events" do
|
||||
original = Application.get_env(:aprsme, :env)
|
||||
Application.put_env(:aprsme, :env, :prod)
|
||||
|
||||
# Attach collectors for every event emitted by the helpers.
|
||||
attach_collector([:aprsme, :postgres, :database])
|
||||
attach_collector([:aprsme, :postgres, :connections])
|
||||
attach_collector([:aprsme, :postgres, :packets_table])
|
||||
# Query stats requires pg_stat_statements which may not be loaded —
|
||||
# don't assert on that one, just call the function.
|
||||
|
||||
try do
|
||||
DatabaseMetrics.collect_postgres_metrics()
|
||||
after
|
||||
Application.put_env(:aprsme, :env, original)
|
||||
end
|
||||
|
||||
assert_receive {:telemetry, [:aprsme, :postgres, :database], %{size_bytes: size}, _}, 2_000
|
||||
assert is_number(size) and size > 0
|
||||
|
||||
assert_receive {:telemetry, [:aprsme, :postgres, :connections], conn_metrics, _}, 2_000
|
||||
assert Map.has_key?(conn_metrics, :total)
|
||||
assert Map.has_key?(conn_metrics, :active)
|
||||
assert Map.has_key?(conn_metrics, :idle)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
55
test/aprsme_web/components/error_boundary_test.exs
Normal file
55
test/aprsme_web/components/error_boundary_test.exs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
defmodule AprsmeWeb.Components.ErrorBoundaryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias AprsmeWeb.Components.ErrorBoundary
|
||||
|
||||
test "renders inner content and default fallback" do
|
||||
html =
|
||||
render_component(&ErrorBoundary.error_boundary/1, %{
|
||||
id: "boundary-1",
|
||||
inner_block: text_slot("hello world"),
|
||||
fallback: []
|
||||
})
|
||||
|
||||
assert html =~ ~s(id="boundary-1")
|
||||
assert html =~ "hello world"
|
||||
assert html =~ "phx-hook=\"ErrorBoundary\""
|
||||
assert html =~ "error-boundary-fallback hidden"
|
||||
# The default error message is rendered when no fallback slot is provided.
|
||||
assert html =~ "Something went wrong"
|
||||
assert html =~ "Refresh page"
|
||||
end
|
||||
|
||||
test "honours custom class" do
|
||||
html =
|
||||
render_component(&ErrorBoundary.error_boundary/1, %{
|
||||
id: "boundary-2",
|
||||
class: "my-custom-class",
|
||||
inner_block: text_slot("content"),
|
||||
fallback: []
|
||||
})
|
||||
|
||||
assert html =~ "my-custom-class"
|
||||
end
|
||||
|
||||
test "uses the custom fallback slot when provided" do
|
||||
html =
|
||||
render_component(&ErrorBoundary.error_boundary/1, %{
|
||||
id: "boundary-3",
|
||||
inner_block: text_slot("content"),
|
||||
fallback: [
|
||||
%{__slot__: :fallback, inner_block: fn _changed, _ -> "custom error!" end}
|
||||
]
|
||||
})
|
||||
|
||||
assert html =~ "custom error!"
|
||||
# Default error message should not appear when a custom fallback is used.
|
||||
refute html =~ "Something went wrong"
|
||||
end
|
||||
|
||||
defp text_slot(text) do
|
||||
[%{__slot__: :inner_block, inner_block: fn _changed, _ -> text end}]
|
||||
end
|
||||
end
|
||||
12
test/aprsme_web/controllers/page_html_test.exs
Normal file
12
test/aprsme_web/controllers/page_html_test.exs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
defmodule AprsmeWeb.PageHTMLTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias AprsmeWeb.PageHTML
|
||||
|
||||
test "packets/1 renders the packets template" do
|
||||
html = render_component(&PageHTML.packets/1, %{})
|
||||
assert html =~ "Packets"
|
||||
end
|
||||
end
|
||||
10
test/aprsme_web/live/about_live_test.exs
Normal file
10
test/aprsme_web/live/about_live_test.exs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
defmodule AprsmeWeb.AboutLiveTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
test "renders the about page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/about", on_error: :warn)
|
||||
assert html =~ "aprs.me"
|
||||
end
|
||||
end
|
||||
59
test/aprsme_web/live/api_docs_live_test.exs
Normal file
59
test/aprsme_web/live/api_docs_live_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
defmodule AprsmeWeb.ApiDocsLiveTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "ApiDocsLive" do
|
||||
test "renders the API documentation page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/api", on_error: :warn)
|
||||
assert html =~ "API Documentation"
|
||||
assert html =~ "/api/v1/callsign"
|
||||
assert html =~ "/api/v1/weather/nearby"
|
||||
end
|
||||
|
||||
test "update_callsign event updates the callsign input", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/api", on_error: :warn)
|
||||
|
||||
rendered =
|
||||
lv
|
||||
|> element("#test_callsign")
|
||||
|> render_change(%{"callsign" => "k1abc"})
|
||||
|
||||
assert rendered =~ "k1abc"
|
||||
end
|
||||
|
||||
test "submitting test_api with empty callsign shows an error", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/api", on_error: :warn)
|
||||
|
||||
rendered =
|
||||
lv
|
||||
|> form("form[phx-submit=\"test_api\"]", %{"callsign" => " "})
|
||||
|> render_submit()
|
||||
|
||||
assert rendered =~ "Please enter a callsign"
|
||||
end
|
||||
|
||||
test "invalid callsign format returns an error response", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/api", on_error: :warn)
|
||||
|
||||
# Seed the callsign via the phx-change so the form submits properly.
|
||||
lv
|
||||
|> element("#test_callsign")
|
||||
|> render_change(%{"callsign" => "BAD!"})
|
||||
|
||||
rendered =
|
||||
lv
|
||||
|> form("form[phx-submit=\"test_api\"]", %{"callsign" => "BAD!"})
|
||||
|> render_submit()
|
||||
|
||||
# The handler sends itself a :call_api message; after it returns the
|
||||
# result assign should contain the invalid-format error JSON.
|
||||
render(lv)
|
||||
assert rendered =~ "Test API" or rendered =~ "Testing..."
|
||||
|
||||
# Wait for the async message handler to complete.
|
||||
:ok = Process.sleep(50)
|
||||
assert render(lv) =~ "Invalid callsign format"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
defmodule AprsmeWeb.UserConfirmationInstructionsLiveTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Aprsme.AccountsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Aprsme.Accounts
|
||||
alias Aprsme.Repo
|
||||
|
||||
describe "Resend confirmation instructions page" do
|
||||
test "renders the page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/users/confirm", on_error: :warn)
|
||||
|
||||
assert html =~ "Resend confirmation instructions"
|
||||
assert html =~ "Log in"
|
||||
assert html =~ "Register"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Resend confirmation" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "sends a new confirmation token for an unconfirmed user", %{conn: conn, user: user} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/confirm", on_error: :warn)
|
||||
|
||||
{:ok, conn} =
|
||||
lv
|
||||
|> form("#resend_confirmation_form", user: %{email: user.email})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
|
||||
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
|
||||
end
|
||||
|
||||
test "does not send confirmation token for unknown email", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/confirm", on_error: :warn)
|
||||
|
||||
{:ok, conn} =
|
||||
lv
|
||||
|> form("#resend_confirmation_form", user: %{email: "unknown@example.com"})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
assert Repo.all(Accounts.UserToken) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
71
test/aprsme_web/live/user_confirmation_live_test.exs
Normal file
71
test/aprsme_web/live/user_confirmation_live_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule AprsmeWeb.UserConfirmationLiveTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Aprsme.AccountsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Aprsme.Accounts
|
||||
alias Aprsme.Repo
|
||||
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
describe "Confirm user" do
|
||||
test "renders confirmation page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token", on_error: :warn)
|
||||
assert html =~ "Confirm your account"
|
||||
end
|
||||
|
||||
test "confirms the given token once", %{conn: conn, user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_confirmation_instructions(user, url)
|
||||
end)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}", on_error: :warn)
|
||||
|
||||
result =
|
||||
lv
|
||||
|> form("#confirmation_form")
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert {:ok, conn} = result
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully"
|
||||
|
||||
assert Accounts.get_user!(user.id).confirmed_at
|
||||
refute conn.assigns[:current_user]
|
||||
assert Repo.all(Accounts.UserToken) == []
|
||||
|
||||
# When not logged in a second confirmation attempt says the token is invalid
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}", on_error: :warn)
|
||||
|
||||
result =
|
||||
lv
|
||||
|> form("#confirmation_form")
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert {:ok, conn} = result
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"User confirmation link is invalid or it has expired"
|
||||
end
|
||||
|
||||
test "does not confirm email with invalid token", %{conn: conn, user: user} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token", on_error: :warn)
|
||||
|
||||
{:ok, conn} =
|
||||
lv
|
||||
|> form("#confirmation_form")
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"User confirmation link is invalid or it has expired"
|
||||
|
||||
refute Accounts.get_user!(user.id).confirmed_at
|
||||
end
|
||||
end
|
||||
end
|
||||
52
test/aprsme_web/live/user_forgot_password_live_test.exs
Normal file
52
test/aprsme_web/live/user_forgot_password_live_test.exs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
defmodule AprsmeWeb.UserForgotPasswordLiveTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Aprsme.AccountsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Aprsme.Accounts
|
||||
alias Aprsme.Repo
|
||||
|
||||
describe "Forgot password page" do
|
||||
test "renders email page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/users/reset_password", on_error: :warn)
|
||||
|
||||
assert html =~ "Forgot your password?"
|
||||
assert html =~ "Log in"
|
||||
assert html =~ "Register"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Reset link" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "sends a new reset password token for a known email", %{conn: conn, user: user} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/reset_password", on_error: :warn)
|
||||
|
||||
{:ok, conn} =
|
||||
lv
|
||||
|> form("#reset_password_form", user: %{"email" => user.email})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
|
||||
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password"
|
||||
end
|
||||
|
||||
test "does not send reset password token for an unknown email", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/reset_password", on_error: :warn)
|
||||
|
||||
{:ok, conn} =
|
||||
lv
|
||||
|> form("#reset_password_form", user: %{"email" => "unknown@example.com"})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/")
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
assert Repo.all(Accounts.UserToken) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
87
test/aprsme_web/live/user_reset_password_live_test.exs
Normal file
87
test/aprsme_web/live/user_reset_password_live_test.exs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
defmodule AprsmeWeb.UserResetPasswordLiveTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Aprsme.AccountsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Aprsme.Accounts
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_reset_password_instructions(user, url)
|
||||
end)
|
||||
|
||||
%{token: token, user: user}
|
||||
end
|
||||
|
||||
describe "Reset password page" do
|
||||
test "renders reset password with valid token", %{conn: conn, token: token} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
|
||||
assert html =~ "Reset Password"
|
||||
end
|
||||
|
||||
test "does not render reset password with invalid token", %{conn: conn} do
|
||||
{:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid", on_error: :warn)
|
||||
|
||||
assert to == %{
|
||||
flash: %{"error" => "Reset password link is invalid or it has expired."},
|
||||
to: "/"
|
||||
}
|
||||
end
|
||||
|
||||
test "validate event re-renders the form", %{conn: conn, token: token} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
|
||||
|
||||
result =
|
||||
lv
|
||||
|> element("#reset_password_form")
|
||||
|> render_change(user: %{"password" => "secret12", "password_confirmation" => "secret123456"})
|
||||
|
||||
# The validate event runs without raising and the form is re-rendered.
|
||||
assert result =~ "Reset Password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Reset Password" do
|
||||
test "resets password once", %{conn: conn, token: token, user: user} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
|
||||
|
||||
{:ok, conn} =
|
||||
lv
|
||||
|> form("#reset_password_form",
|
||||
user: %{
|
||||
"password" => "new valid password",
|
||||
"password_confirmation" => "new valid password"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, ~p"/users/log_in")
|
||||
|
||||
refute get_session(conn, :user_token)
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
|
||||
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
|
||||
end
|
||||
|
||||
test "does not reset password on invalid data", %{conn: conn, token: token} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}", on_error: :warn)
|
||||
|
||||
result =
|
||||
lv
|
||||
|> form("#reset_password_form",
|
||||
user: %{
|
||||
"password" => "too short",
|
||||
"password_confirmation" => "does not match"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
# The template shows a generic "Oops, something went wrong!" banner when
|
||||
# the changeset action is :insert, rather than per-field errors.
|
||||
assert result =~ "Reset Password"
|
||||
assert result =~ "Oops, something went wrong!"
|
||||
end
|
||||
end
|
||||
end
|
||||
81
test/aprsme_web/live/weather_live/callsign_view_test.exs
Normal file
81
test/aprsme_web/live/weather_live/callsign_view_test.exs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
defmodule AprsmeWeb.WeatherLive.CallsignViewTest do
|
||||
use AprsmeWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias AprsmeWeb.WeatherLive.CallsignView
|
||||
|
||||
describe "mount via HTTP" do
|
||||
test "renders the weather view for a callsign", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/weather/N0CALL", on_error: :warn)
|
||||
assert html =~ "N0CALL"
|
||||
end
|
||||
|
||||
test "normalizes lowercase callsign to uppercase", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/weather/k1abc", on_error: :warn)
|
||||
assert html =~ "K1ABC"
|
||||
end
|
||||
end
|
||||
|
||||
describe "has_weather_field?/2" do
|
||||
test "returns false for missing fields" do
|
||||
refute CallsignView.has_weather_field?(%{}, :temperature)
|
||||
end
|
||||
|
||||
test "returns false for nil values" do
|
||||
refute CallsignView.has_weather_field?(%{temperature: nil}, :temperature)
|
||||
end
|
||||
|
||||
test "returns true for numeric values" do
|
||||
assert CallsignView.has_weather_field?(%{temperature: 72.5}, :temperature)
|
||||
end
|
||||
|
||||
test "returns true for non-empty string values" do
|
||||
assert CallsignView.has_weather_field?(%{temperature: "72.5"}, :temperature)
|
||||
end
|
||||
|
||||
test "returns false for empty string" do
|
||||
refute CallsignView.has_weather_field?(%{temperature: ""}, :temperature)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_weather_field_zero/2" do
|
||||
test "replaces N/A with 0" do
|
||||
assert CallsignView.get_weather_field_zero(%{}, :temperature) == "0"
|
||||
end
|
||||
|
||||
test "passes numeric values through" do
|
||||
assert CallsignView.get_weather_field_zero(%{temperature: 72.5}, :temperature) == 72.5
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_weather_value/3" do
|
||||
test "returns nil for missing values" do
|
||||
assert CallsignView.format_weather_value(%{}, :temperature, "en") == nil
|
||||
end
|
||||
|
||||
test "formats numeric temperature with units" do
|
||||
result = CallsignView.format_weather_value(%{temperature: 72.5}, :temperature, "en")
|
||||
assert is_binary(result)
|
||||
assert result =~ "72"
|
||||
end
|
||||
|
||||
test "formats numeric wind_speed with a space separator" do
|
||||
result = CallsignView.format_weather_value(%{wind_speed: 10.0}, :wind_speed, "en")
|
||||
assert is_binary(result)
|
||||
# Unit appears after the numeric value with a separator.
|
||||
assert String.contains?(result, " ")
|
||||
end
|
||||
|
||||
test "handles string numeric values" do
|
||||
result = CallsignView.format_weather_value(%{temperature: "72.5"}, :temperature, "en")
|
||||
assert is_binary(result)
|
||||
end
|
||||
|
||||
test "handles string non-numeric values for untracked keys" do
|
||||
# humidity has no entry in @weather_formatters, so binary values are
|
||||
# returned as-is.
|
||||
assert CallsignView.format_weather_value(%{humidity: "65"}, :humidity, "en") == "65"
|
||||
end
|
||||
end
|
||||
end
|
||||
65
test/mix/tasks/aprs.parse_file_test.exs
Normal file
65
test/mix/tasks/aprs.parse_file_test.exs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
defmodule Mix.Tasks.Aprs.ParseFileTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Mix.Tasks.Aprs.ParseFile
|
||||
|
||||
@tmp_dir System.tmp_dir!()
|
||||
|
||||
setup do
|
||||
input = Path.join(@tmp_dir, "aprs_parse_file_input_#{System.unique_integer([:positive])}.txt")
|
||||
output = Path.join(@tmp_dir, "aprs_parse_file_output_#{System.unique_integer([:positive])}.txt")
|
||||
|
||||
on_exit(fn ->
|
||||
File.rm(input)
|
||||
File.rm(output)
|
||||
end)
|
||||
|
||||
%{input: input, output: output}
|
||||
end
|
||||
|
||||
test "skips blank and comment lines and writes failed lines to the output", %{
|
||||
input: input,
|
||||
output: output
|
||||
} do
|
||||
File.write!(input, """
|
||||
# a comment
|
||||
|
||||
N0CALL>APRS:this is not a valid packet at all
|
||||
""")
|
||||
|
||||
# Mix tasks emit output via Mix.shell; silence it during the test.
|
||||
Mix.shell(Mix.Shell.Process)
|
||||
ParseFile.run([input, "--output", output])
|
||||
|
||||
assert File.exists?(output)
|
||||
contents = File.read!(output)
|
||||
|
||||
# The garbage line should be written; comments and blank lines should not.
|
||||
refute contents =~ "# a comment"
|
||||
# The parser may or may not accept the "not a valid packet" line depending
|
||||
# on its lenience — but if anything is written, it's that one line.
|
||||
case String.trim(contents) do
|
||||
"" -> :ok
|
||||
line -> assert line == "N0CALL>APRS:this is not a valid packet at all"
|
||||
end
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "Processed"
|
||||
end
|
||||
|
||||
test "errors and exits when input file is missing", %{output: output} do
|
||||
Mix.shell(Mix.Shell.Process)
|
||||
|
||||
missing = Path.join(@tmp_dir, "does_not_exist_#{System.unique_integer([:positive])}.txt")
|
||||
|
||||
try do
|
||||
ParseFile.run([missing, "--output", output])
|
||||
flunk("expected run to exit")
|
||||
catch
|
||||
:exit, {:shutdown, 1} -> :ok
|
||||
end
|
||||
|
||||
assert_received {:mix_shell, :error, [err]}
|
||||
assert err =~ "input file not found"
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue