Adds tests covering: - Aprsme.Packet edge cases (already committed) - Encoding valid_codepoint? branches via slow grapheme path and encoding_info shapes - ErrorHandler database/network error categorization (CastError, ConstraintError, QueryError, Postgrex.Error, RuntimeError circuit_open/timeout, custom map :reason) - HealthCheck plug ask_shutting_down? nil and unresponsive-pid paths - SpatialPubSub ensure_float branches, dateline broadcast wrap-around, monitored client DOWN handler, no-coordinate broadcast skip - DataBuilder weather popup, weather-only fallback, tuple value conversion, string weather convert_unit branches - DeviceIdentification fetch_devices_from_url default-arg head - PageController format_uptime branches via fake Aprsme.Is GenServer - WeatherLive.CallsignView compare_timestamps branches via :weather_packet messages - PacketConsumer field-conversion private helpers via crafted handle_events events
349 lines
11 KiB
Elixir
349 lines
11 KiB
Elixir
defmodule Aprsme.ErrorHandlerTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Aprsme.ErrorHandler
|
|
|
|
# ── helpers to build error structs without hitting complex exception/1 ──
|
|
|
|
defp cast_error(message \\ "cannot cast value") do
|
|
%Ecto.Query.CastError{type: :string, value: 42, message: message}
|
|
end
|
|
|
|
defp constraint_error(constraint \\ "users_email_index") do
|
|
%Ecto.ConstraintError{type: :unique, constraint: constraint, message: "constraint error"}
|
|
end
|
|
|
|
defp postgrex_unique_violation do
|
|
%Postgrex.Error{postgres: %{code: :unique_violation}, message: nil, connection_id: nil}
|
|
end
|
|
|
|
defp db_connection_error do
|
|
%DBConnection.ConnectionError{message: "connection refused", severity: :error, reason: :error}
|
|
end
|
|
|
|
defp query_error(message \\ "invalid query") do
|
|
%Ecto.QueryError{message: message}
|
|
end
|
|
|
|
defp transport_error(reason) do
|
|
%Req.TransportError{reason: reason}
|
|
end
|
|
|
|
# ── handle_database_error/2 ──
|
|
|
|
describe "handle_database_error/2" do
|
|
test "CastError returns {:error, :invalid_data}" do
|
|
assert {:error, :invalid_data} = ErrorHandler.handle_database_error(cast_error())
|
|
end
|
|
|
|
test "ConstraintError returns {:error, :constraint_violation}" do
|
|
assert {:error, :constraint_violation} =
|
|
ErrorHandler.handle_database_error(constraint_error())
|
|
end
|
|
|
|
test "Postgrex.Error with unique_violation returns {:error, :duplicate_record}" do
|
|
assert {:error, :duplicate_record} =
|
|
ErrorHandler.handle_database_error(postgrex_unique_violation())
|
|
end
|
|
|
|
test "DBConnection.ConnectionError returns {:error, :database_unavailable}" do
|
|
assert {:error, :database_unavailable} =
|
|
ErrorHandler.handle_database_error(db_connection_error())
|
|
end
|
|
|
|
test "Ecto.QueryError returns {:error, :query_failed}" do
|
|
assert {:error, :query_failed} = ErrorHandler.handle_database_error(query_error())
|
|
end
|
|
|
|
test "catch-all returns {:error, :database_error}" do
|
|
assert {:error, :database_error} =
|
|
ErrorHandler.handle_database_error(%RuntimeError{message: "boom"})
|
|
end
|
|
|
|
test "passes context through" do
|
|
assert {:error, :invalid_data} =
|
|
ErrorHandler.handle_database_error(cast_error(), %{table: "users"})
|
|
end
|
|
end
|
|
|
|
# ── handle_network_error/3 ──
|
|
|
|
describe "handle_network_error/3" do
|
|
test "Req.TransportError with :timeout returns {:error, :timeout}" do
|
|
assert {:error, :timeout} =
|
|
ErrorHandler.handle_network_error(transport_error(:timeout), :aprs_is)
|
|
end
|
|
|
|
test "Req.TransportError with :econnrefused returns {:error, :service_unavailable}" do
|
|
assert {:error, :service_unavailable} =
|
|
ErrorHandler.handle_network_error(transport_error(:econnrefused), :aprs_is)
|
|
end
|
|
|
|
test "Req.TransportError with other reason returns {:error, :network_error}" do
|
|
assert {:error, :network_error} =
|
|
ErrorHandler.handle_network_error(transport_error(:nxdomain), :geocoder)
|
|
end
|
|
|
|
test "{:error, :circuit_open} returns {:error, :service_unavailable}" do
|
|
assert {:error, :service_unavailable} =
|
|
ErrorHandler.handle_network_error({:error, :circuit_open}, :weather_api)
|
|
end
|
|
|
|
test "catch-all returns {:error, :network_error}" do
|
|
assert {:error, :network_error} =
|
|
ErrorHandler.handle_network_error(:some_unknown_error, :some_service)
|
|
end
|
|
|
|
test "passes context through" do
|
|
assert {:error, :timeout} =
|
|
ErrorHandler.handle_network_error(
|
|
transport_error(:timeout),
|
|
:aprs_is,
|
|
%{attempt: 1}
|
|
)
|
|
end
|
|
end
|
|
|
|
# ── handle_packet_error/3 ──
|
|
|
|
describe "handle_packet_error/3" do
|
|
test "Jason.DecodeError returns {:error, :invalid_json}" do
|
|
error = %Jason.DecodeError{position: 0, token: nil, data: "bad"}
|
|
|
|
assert {:error, :invalid_json} =
|
|
ErrorHandler.handle_packet_error(error, %{sender: "N0CALL"})
|
|
end
|
|
|
|
test "ArgumentError with 'invalid' in message returns {:error, :invalid_format}" do
|
|
error = %ArgumentError{message: "invalid byte sequence"}
|
|
|
|
assert {:error, :invalid_format} =
|
|
ErrorHandler.handle_packet_error(error, %{sender: "N0CALL"})
|
|
end
|
|
|
|
test "ArgumentError without 'invalid' in message returns {:error, :processing_failed}" do
|
|
error = %ArgumentError{message: "bad argument"}
|
|
|
|
assert {:error, :processing_failed} =
|
|
ErrorHandler.handle_packet_error(error, %{sender: "N0CALL"})
|
|
end
|
|
|
|
test "catch-all returns {:error, :processing_failed}" do
|
|
error = %RuntimeError{message: "something went wrong"}
|
|
|
|
assert {:error, :processing_failed} =
|
|
ErrorHandler.handle_packet_error(error, %{sender: "N0CALL"})
|
|
end
|
|
|
|
test "uses 'unknown' when packet_data has no :sender key" do
|
|
error = %Jason.DecodeError{position: 0, token: nil, data: "bad"}
|
|
|
|
assert {:error, :invalid_json} = ErrorHandler.handle_packet_error(error, %{})
|
|
end
|
|
|
|
test "passes context through" do
|
|
error = %Jason.DecodeError{position: 0, token: nil, data: "bad"}
|
|
|
|
assert {:error, :invalid_json} =
|
|
ErrorHandler.handle_packet_error(error, %{sender: "N0CALL"}, %{source: "is"})
|
|
end
|
|
end
|
|
|
|
# ── handle_process_error/3 ──
|
|
|
|
describe "handle_process_error/3" do
|
|
test ":timeout returns {:error, :timeout}" do
|
|
assert {:error, :timeout} = ErrorHandler.handle_process_error(:timeout, :my_genserver)
|
|
end
|
|
|
|
test "{:noproc, _} returns {:error, :process_unavailable}" do
|
|
assert {:error, :process_unavailable} =
|
|
ErrorHandler.handle_process_error({:noproc, {GenServer, :call, [:pid]}}, :worker)
|
|
end
|
|
|
|
test "{:badrpc, reason} returns {:error, :rpc_failed}" do
|
|
assert {:error, :rpc_failed} =
|
|
ErrorHandler.handle_process_error({:badrpc, :nodedown}, :remote_node)
|
|
end
|
|
|
|
test "catch-all returns {:error, :process_error}" do
|
|
assert {:error, :process_error} =
|
|
ErrorHandler.handle_process_error(:something_else, :my_process)
|
|
end
|
|
|
|
test "passes context through" do
|
|
assert {:error, :timeout} =
|
|
ErrorHandler.handle_process_error(:timeout, :my_genserver, %{call: :get_state})
|
|
end
|
|
end
|
|
|
|
# ── with_error_handling/2 ──
|
|
|
|
describe "with_error_handling/2" do
|
|
test "success path returns {:ok, value}" do
|
|
assert {:ok, 42} = ErrorHandler.with_error_handling(fn -> 42 end)
|
|
end
|
|
|
|
test "success path returns {:ok, value} for complex results" do
|
|
assert {:ok, %{key: "value"}} = ErrorHandler.with_error_handling(fn -> %{key: "value"} end)
|
|
end
|
|
|
|
test "raise with no retries goes to categorize_and_handle_error" do
|
|
result =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise RuntimeError, "boom"
|
|
end)
|
|
|
|
assert {:error, :unknown_error} = result
|
|
end
|
|
|
|
test "raise with retries retries before failing" do
|
|
{:ok, counter} = Agent.start_link(fn -> 0 end)
|
|
|
|
result =
|
|
ErrorHandler.with_error_handling(
|
|
fn ->
|
|
count = Agent.get_and_update(counter, fn n -> {n, n + 1} end)
|
|
|
|
if count < 2 do
|
|
raise RuntimeError, "boom"
|
|
end
|
|
|
|
:success
|
|
end,
|
|
max_retries: 2,
|
|
retry_delay: 1
|
|
)
|
|
|
|
assert {:ok, :success} = result
|
|
|
|
final_count = Agent.get(counter, & &1)
|
|
assert final_count == 3
|
|
|
|
Agent.stop(counter)
|
|
end
|
|
|
|
test "raise with retries exhausted calls categorize_and_handle_error" do
|
|
{:ok, counter} = Agent.start_link(fn -> 0 end)
|
|
|
|
result =
|
|
ErrorHandler.with_error_handling(
|
|
fn ->
|
|
Agent.update(counter, &(&1 + 1))
|
|
raise RuntimeError, "always fails"
|
|
end,
|
|
max_retries: 2,
|
|
retry_delay: 1
|
|
)
|
|
|
|
assert {:error, :unknown_error} = result
|
|
|
|
# Initial attempt + 2 retries = 3 total calls
|
|
final_count = Agent.get(counter, & &1)
|
|
assert final_count == 3
|
|
|
|
Agent.stop(counter)
|
|
end
|
|
|
|
test "throw path returns {:error, :unexpected_error}" do
|
|
result =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
throw(:something_bad)
|
|
end)
|
|
|
|
assert {:error, :unexpected_error} = result
|
|
end
|
|
|
|
test "database error routes through handle_database_error" do
|
|
result =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise DBConnection.ConnectionError, "connection refused"
|
|
end)
|
|
|
|
assert {:error, :database_unavailable} = result
|
|
end
|
|
|
|
test "network error routes through handle_network_error" do
|
|
result =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise Req.TransportError, reason: :timeout
|
|
end)
|
|
|
|
assert {:error, :timeout} = result
|
|
end
|
|
|
|
test "CastError is categorized as a database error" do
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise Ecto.CastError, type: :integer, value: "x", message: "invalid cast"
|
|
end)
|
|
end
|
|
|
|
test "Ecto.ConstraintError is categorized as a database error" do
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise Ecto.ConstraintError,
|
|
type: :unique,
|
|
constraint: "x",
|
|
action: :insert,
|
|
changeset: nil,
|
|
message: "violation"
|
|
end)
|
|
end
|
|
|
|
test "Ecto.QueryError is categorized as a database error" do
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise Ecto.QueryError, query: "fake", message: "bad query"
|
|
end)
|
|
end
|
|
|
|
test "Postgrex.Error is categorized as a database error" do
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise Postgrex.Error, message: "boom"
|
|
end)
|
|
end
|
|
|
|
test "circuit_open RuntimeError is categorized as a network error" do
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise RuntimeError, "circuit_open"
|
|
end)
|
|
end
|
|
|
|
test "timeout RuntimeError is categorized as a network error" do
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise RuntimeError, "timeout"
|
|
end)
|
|
end
|
|
|
|
test "map with reason: :circuit_open is categorized as network error via raise" do
|
|
# Raising a struct that pattern-matches the network_error?/1 map clause.
|
|
defmodule CircuitErr do
|
|
@moduledoc false
|
|
defexception [:reason, message: "circuit_open"]
|
|
end
|
|
|
|
assert {:error, _} =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise CircuitErr, reason: :circuit_open
|
|
end)
|
|
end
|
|
|
|
test "uncategorized error returns {:error, :unknown_error}" do
|
|
result =
|
|
ErrorHandler.with_error_handling(fn ->
|
|
raise ArgumentError, "nope"
|
|
end)
|
|
|
|
assert {:error, :unknown_error} = result
|
|
end
|
|
|
|
test "accepts context option" do
|
|
assert {:ok, :fine} =
|
|
ErrorHandler.with_error_handling(fn -> :fine end, context: %{source: "test"})
|
|
end
|
|
end
|
|
end
|