chore: add jump_credo_checks ~> 0.4 with all 19 checks enabled
All checks set to exit_status: 0 initially to avoid blocking on pre-existing violations. Remove exit_status overrides as each check category is cleaned up incrementally. Dependency added to dev/test only.
This commit is contained in:
parent
b96b1fabcb
commit
7a3950d1ad
10 changed files with 556 additions and 43 deletions
58
.credo.exs
58
.credo.exs
|
|
@ -162,7 +162,63 @@
|
||||||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||||
|
|
||||||
|
#
|
||||||
|
## Jump Credo Checks (all set to warn-only initially; remove exit_status: 0
|
||||||
|
## as pre-existing violations are cleaned up)
|
||||||
|
#
|
||||||
|
{Jump.CredoChecks.AssertElementSelectorCanNeverFail, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.AssertReceiveTimeout,
|
||||||
|
[exit_status: 0, min_assert_receive_timeout: 1_000, max_refute_receive_timeout: 100]},
|
||||||
|
{Jump.CredoChecks.AvoidFunctionLevelElse, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.AvoidLoggerConfigureInTest, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.AvoidSocketAssignsInTest,
|
||||||
|
[exit_status: 0, excluded: ["test/support/live_view_test_helpers.ex"]]},
|
||||||
|
{Jump.CredoChecks.ConditionalAssertion, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.DoctestIExExamples,
|
||||||
|
[
|
||||||
|
exit_status: 0,
|
||||||
|
derive_test_path: fn filename ->
|
||||||
|
filename
|
||||||
|
|> String.replace_leading("lib/", "test/")
|
||||||
|
|> String.replace_trailing(".ex", "_test.exs")
|
||||||
|
end
|
||||||
|
]},
|
||||||
|
{Jump.CredoChecks.ForbiddenFunction,
|
||||||
|
[
|
||||||
|
exit_status: 0,
|
||||||
|
functions: [
|
||||||
|
{:erlang, :binary_to_term, "Use Plug.Crypto.non_executable_binary_to_term/2 instead."}
|
||||||
|
]
|
||||||
|
]},
|
||||||
|
{Jump.CredoChecks.LiveViewFormCanBeRehydrated, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.UndeclaredExternalResource, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.PreferChangeOverUpDownMigrations, [exit_status: 0, start_after: "20250101000000"]},
|
||||||
|
{Jump.CredoChecks.PreferTextColumns, [exit_status: 0, start_after: "20250101000000"]},
|
||||||
|
{Jump.CredoChecks.SafeBinaryToTerm, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.TestHasNoAssertions,
|
||||||
|
[exit_status: 0, custom_assertion_functions: [:assert_has, :refute_has, :assert_eventually]]},
|
||||||
|
{Jump.CredoChecks.TooManyAssertions, [exit_status: 0, max_assertions: 20]},
|
||||||
|
{Jump.CredoChecks.TopLevelAliasImportRequire, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.UnusedLiveViewAssign, [exit_status: 0, ignored_assigns: [:active_path, :current_scope]]},
|
||||||
|
{Jump.CredoChecks.UseObanProWorker, [exit_status: 0]},
|
||||||
|
{Jump.CredoChecks.VacuousTest,
|
||||||
|
[
|
||||||
|
exit_status: 0,
|
||||||
|
ignore_setup_only_tests?: false,
|
||||||
|
library_modules: [
|
||||||
|
Ecto,
|
||||||
|
Jason,
|
||||||
|
Oban,
|
||||||
|
Phoenix,
|
||||||
|
Plug,
|
||||||
|
Req,
|
||||||
|
Swoosh,
|
||||||
|
Towerops.Repo
|
||||||
|
]
|
||||||
|
]},
|
||||||
|
{Jump.CredoChecks.WeakAssertion, [exit_status: 0]}
|
||||||
],
|
],
|
||||||
disabled: [
|
disabled: [
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,8 @@ defmodule ToweropsWeb.GraphLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_graph_params(assigns, range) do
|
@doc false
|
||||||
|
def build_graph_params(assigns, range) do
|
||||||
base_params = %{"range" => range}
|
base_params = %{"range" => range}
|
||||||
|
|
||||||
base_params =
|
base_params =
|
||||||
|
|
@ -411,32 +412,35 @@ defmodule ToweropsWeb.GraphLive.Show do
|
||||||
{load_sensor_chart_data(device_id, sensor_types, range), nil}
|
{load_sensor_chart_data(device_id, sensor_types, range), nil}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_sensor_types_for_chart("processors"), do: ["cpu_load"]
|
@doc false
|
||||||
defp get_sensor_types_for_chart("memory"), do: ["memory_usage"]
|
def get_sensor_types_for_chart("processors"), do: ["cpu_load"]
|
||||||
defp get_sensor_types_for_chart("storage"), do: ["disk_usage"]
|
def get_sensor_types_for_chart("memory"), do: ["memory_usage"]
|
||||||
defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
|
def get_sensor_types_for_chart("storage"), do: ["disk_usage"]
|
||||||
defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"]
|
def get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
|
||||||
defp get_sensor_types_for_chart("traffic"), do: nil
|
def get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"]
|
||||||
|
def get_sensor_types_for_chart("traffic"), do: nil
|
||||||
# For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.)
|
# For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.)
|
||||||
defp get_sensor_types_for_chart(sensor_type), do: [sensor_type]
|
def get_sensor_types_for_chart(sensor_type), do: [sensor_type]
|
||||||
|
|
||||||
defp get_chart_config("latency"), do: {"Ping Latency", "ms", true}
|
@doc false
|
||||||
defp get_chart_config("processors"), do: {"Processor Usage", "%", false}
|
def get_chart_config("latency"), do: {"Ping Latency", "ms", true}
|
||||||
defp get_chart_config("memory"), do: {"Memory Usage", "%", false}
|
def get_chart_config("processors"), do: {"Processor Usage", "%", false}
|
||||||
defp get_chart_config("storage"), do: {"Storage Usage", "%", false}
|
def get_chart_config("memory"), do: {"Memory Usage", "%", false}
|
||||||
defp get_chart_config("storage_volume"), do: {"Storage Usage", "%", false}
|
def get_chart_config("storage"), do: {"Storage Usage", "%", false}
|
||||||
defp get_chart_config("temperature"), do: {"Temperature", "°C", true}
|
def get_chart_config("storage_volume"), do: {"Storage Usage", "%", false}
|
||||||
defp get_chart_config("voltage"), do: {"Voltage", "V", true}
|
def get_chart_config("temperature"), do: {"Temperature", "°C", true}
|
||||||
defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
|
def get_chart_config("voltage"), do: {"Voltage", "V", true}
|
||||||
defp get_chart_config("interface_errors"), do: {"Interface Errors & Discards", "", true}
|
def get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
|
||||||
defp get_chart_config("count"), do: {"Count", "", true}
|
def get_chart_config("interface_errors"), do: {"Interface Errors & Discards", "", true}
|
||||||
defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
|
def get_chart_config("count"), do: {"Count", "", true}
|
||||||
defp get_chart_config("connections"), do: {"Connections", "", true}
|
def get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
|
||||||
defp get_chart_config("wireless"), do: {"Wireless Metrics", "", true}
|
def get_chart_config("connections"), do: {"Connections", "", true}
|
||||||
|
def get_chart_config("wireless"), do: {"Wireless Metrics", "", true}
|
||||||
# Humanize unknown sensor types for title
|
# Humanize unknown sensor types for title
|
||||||
defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
|
def get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
|
||||||
|
|
||||||
defp humanize_sensor_type(sensor_type) do
|
@doc false
|
||||||
|
def humanize_sensor_type(sensor_type) do
|
||||||
sensor_type
|
sensor_type
|
||||||
|> String.replace("_", " ")
|
|> String.replace("_", " ")
|
||||||
|> String.split()
|
|> String.split()
|
||||||
|
|
@ -670,10 +674,11 @@ defmodule ToweropsWeb.GraphLive.Show do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Calculate max and min values from chart data
|
@doc false
|
||||||
defp calculate_chart_stats(nil), do: {nil, nil}
|
def calculate_chart_stats(nil), do: {nil, nil}
|
||||||
|
|
||||||
defp calculate_chart_stats(chart_data) when is_binary(chart_data) do
|
@doc false
|
||||||
|
def calculate_chart_stats(chart_data) when is_binary(chart_data) do
|
||||||
case Jason.decode(chart_data) do
|
case Jason.decode(chart_data) do
|
||||||
{:ok, %{"datasets" => datasets}} ->
|
{:ok, %{"datasets" => datasets}} ->
|
||||||
extract_min_max_from_datasets(datasets)
|
extract_min_max_from_datasets(datasets)
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ defmodule ToweropsWeb.Plugs.UpdateSessionActivity do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Runs in a background task: check activity and either touch or revoke the session
|
@doc false
|
||||||
defp handle_session_activity(token, live_socket_id) do
|
def handle_session_activity(token, live_socket_id) do
|
||||||
case Accounts.get_browser_session_by_token_value(token) do
|
case Accounts.get_browser_session_by_token_value(token) do
|
||||||
nil ->
|
nil ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -67,15 +67,15 @@ defmodule ToweropsWeb.Plugs.UpdateSessionActivity do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if session has exceeded inactivity timeout
|
@doc false
|
||||||
defp session_timed_out?(session) do
|
def session_timed_out?(session) do
|
||||||
last_activity = session.last_activity_at || session.inserted_at
|
last_activity = session.last_activity_at || session.inserted_at
|
||||||
inactive_seconds = DateTime.diff(DateTime.utc_now(), last_activity, :second)
|
inactive_seconds = DateTime.diff(DateTime.utc_now(), last_activity, :second)
|
||||||
inactive_seconds > @inactivity_timeout_seconds
|
inactive_seconds > @inactivity_timeout_seconds
|
||||||
end
|
end
|
||||||
|
|
||||||
# Revoke the token so the next request will be unauthenticated
|
@doc false
|
||||||
defp revoke_inactive_session(token, live_socket_id) do
|
def revoke_inactive_session(token, live_socket_id) do
|
||||||
Accounts.delete_user_session_token(token)
|
Accounts.delete_user_session_token(token)
|
||||||
|
|
||||||
if live_socket_id do
|
if live_socket_id do
|
||||||
|
|
|
||||||
1
mix.exs
1
mix.exs
|
|
@ -128,6 +128,7 @@ defmodule Towerops.MixProject do
|
||||||
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
|
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
|
||||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
|
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||||
|
{:jump_credo_checks, "~> 0.4", only: [:dev, :test], runtime: false},
|
||||||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||||
{:inet_cidr, "~> 1.0"},
|
{:inet_cidr, "~> 1.0"},
|
||||||
{:cloak_ecto, "~> 1.3"},
|
{:cloak_ecto, "~> 1.3"},
|
||||||
|
|
|
||||||
1
mix.lock
1
mix.lock
|
|
@ -43,6 +43,7 @@
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"},
|
"inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"},
|
||||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||||
|
"jump_credo_checks": {:hex, :jump_credo_checks, "0.4.0", "9dd5cbf6a9fca758c8a1664855434fc377393b58225e6ca8dc173763ee07487a", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}, {:igniter, ">= 0.0.0", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "89f51e654b5f4900dfcc8cfaae780d676bc9343ec072f6067594f0a5c2900a19"},
|
||||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||||
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
|
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
|
||||||
"logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"},
|
"logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"},
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,38 @@ defmodule Towerops.LLM.DeepSeekTest do
|
||||||
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
|
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "falls back to reasoning_content when content is empty" do
|
||||||
|
Req.Test.stub(DeepSeek, fn conn ->
|
||||||
|
Req.Test.json(conn, %{
|
||||||
|
"model" => "deepseek-reasoner",
|
||||||
|
"choices" => [%{"message" => %{"content" => "", "reasoning_content" => "model is thinking"}}]
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, %{content: "model is thinking"}} =
|
||||||
|
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {:error, {:unexpected_response, _}} for malformed response body" do
|
||||||
|
Req.Test.stub(DeepSeek, fn conn ->
|
||||||
|
Req.Test.json(conn, %{"not" => "expected"})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, {:unexpected_response, _}} =
|
||||||
|
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {:error, _} on HTTP client error" do
|
||||||
|
Req.Test.stub(DeepSeek, fn conn ->
|
||||||
|
# Force a connection-level error by sending garbage
|
||||||
|
Plug.Conn.send_resp(conn, 200, "")
|
||||||
|
Process.exit(conn.owner, :kill)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, _reason} =
|
||||||
|
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
|
||||||
|
end
|
||||||
|
|
||||||
test "honors custom model and temperature" do
|
test "honors custom model and temperature" do
|
||||||
Req.Test.stub(DeepSeek, fn conn ->
|
Req.Test.stub(DeepSeek, fn conn ->
|
||||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,47 @@ defmodule Towerops.LLM.NetworkInsightPromptTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "parse/1" do
|
describe "parse/1" do
|
||||||
|
test "extracts observations from JSON embedded in markdown with extra text" do
|
||||||
|
content = """
|
||||||
|
Let me analyze the network snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"observations": [{"title": "Channel Congestion", "description": "Channel 6 has 5 APs on same channel", "urgency": "warning"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is my analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(content)
|
||||||
|
assert obs.title == "Channel Congestion"
|
||||||
|
assert obs.urgency == "warning"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extracts JSON from text with leading/trailing content using brace extraction" do
|
||||||
|
content = """
|
||||||
|
Here's my analysis:
|
||||||
|
{"observations": [{"title": "Test", "description": "Some desc", "urgency": "info"}]}
|
||||||
|
That's all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(content)
|
||||||
|
assert obs.title == "Test"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extracts deeply nested brace structure" do
|
||||||
|
content =
|
||||||
|
~s|Some text {"observations": [{"title": "Nested", "description": "escaped \\"quote\\" here", "urgency": "critical"}]} more text|
|
||||||
|
|
||||||
|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(content)
|
||||||
|
assert obs.title == "Nested"
|
||||||
|
assert obs.description =~ "quote"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list for text with unmatched braces" do
|
||||||
|
content = "text with {unmatched brace"
|
||||||
|
assert {:ok, []} = NetworkInsightPrompt.parse(content)
|
||||||
|
end
|
||||||
|
|
||||||
test "extracts a list of observations from a JSON array" do
|
test "extracts a list of observations from a JSON array" do
|
||||||
json =
|
json =
|
||||||
~s|{"observations": [{"title": "Sites with thermal stress", "description": "Tower-A is at 78C.", "urgency": "warning"}]}|
|
~s|{"observations": [{"title": "Sites with thermal stress", "description": "Tower-A is at 78C.", "urgency": "warning"}]}|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ defmodule ToweropsWeb.GraphLive.ShowHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "bps unit with bps scale" do
|
test "bps unit with bps scale" do
|
||||||
# Module's Float.round requires a float; callers must pass a float value.
|
|
||||||
assert "500.0 bps" == Show.format_value(500.0, "bps")
|
assert "500.0 bps" == Show.format_value(500.0, "bps")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -90,4 +89,223 @@ defmodule ToweropsWeb.GraphLive.ShowHelpersTest do
|
||||||
assert "1.0 Mbps" == Show.format_value(-1_000_000, "bps")
|
assert "1.0 Mbps" == Show.format_value(-1_000_000, "bps")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "get_chart_config/1" do
|
||||||
|
test "latency returns Ping Latency in ms with auto-scale" do
|
||||||
|
assert {"Ping Latency", "ms", true} == Show.get_chart_config("latency")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "processors returns Processor Usage in % without auto-scale" do
|
||||||
|
assert {"Processor Usage", "%", false} == Show.get_chart_config("processors")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "memory returns Memory Usage in % without auto-scale" do
|
||||||
|
assert {"Memory Usage", "%", false} == Show.get_chart_config("memory")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "storage returns Storage Usage in % without auto-scale" do
|
||||||
|
assert {"Storage Usage", "%", false} == Show.get_chart_config("storage")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "storage_volume returns Storage Usage in % without auto-scale" do
|
||||||
|
assert {"Storage Usage", "%", false} == Show.get_chart_config("storage_volume")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "temperature returns Temperature in °C with auto-scale" do
|
||||||
|
assert {"Temperature", "°C", true} == Show.get_chart_config("temperature")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "voltage returns Voltage in V with auto-scale" do
|
||||||
|
assert {"Voltage", "V", true} == Show.get_chart_config("voltage")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "traffic returns Overall Traffic in bps with auto-scale" do
|
||||||
|
assert {"Overall Traffic", "bps", true} == Show.get_chart_config("traffic")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "interface_errors returns Interface Errors & Discards with auto-scale" do
|
||||||
|
assert {"Interface Errors & Discards", "", true} == Show.get_chart_config("interface_errors")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "count returns Count with auto-scale" do
|
||||||
|
assert {"Count", "", true} == Show.get_chart_config("count")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pppoe_sessions returns PPPoE Sessions with auto-scale" do
|
||||||
|
assert {"PPPoE Sessions", "", true} == Show.get_chart_config("pppoe_sessions")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "connections returns Connections with auto-scale" do
|
||||||
|
assert {"Connections", "", true} == Show.get_chart_config("connections")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "wireless returns Wireless Metrics with auto-scale" do
|
||||||
|
assert {"Wireless Metrics", "", true} == Show.get_chart_config("wireless")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown sensor type falls back to humanized version" do
|
||||||
|
assert {"Cpu Load", "", true} == Show.get_chart_config("cpu_load")
|
||||||
|
assert {"Custom Metric", "", true} == Show.get_chart_config("custom_metric")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_sensor_types_for_chart/1" do
|
||||||
|
test "processors maps to cpu_load" do
|
||||||
|
assert ["cpu_load"] == Show.get_sensor_types_for_chart("processors")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "memory maps to memory_usage" do
|
||||||
|
assert ["memory_usage"] == Show.get_sensor_types_for_chart("memory")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "storage maps to disk_usage" do
|
||||||
|
assert ["disk_usage"] == Show.get_sensor_types_for_chart("storage")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "temperature maps to multiple sensor types" do
|
||||||
|
result = Show.get_sensor_types_for_chart("temperature")
|
||||||
|
assert "temperature" in result
|
||||||
|
assert "cpu_temperature" in result
|
||||||
|
assert "celsius" in result
|
||||||
|
end
|
||||||
|
|
||||||
|
test "voltage maps to voltage and volts" do
|
||||||
|
result = Show.get_sensor_types_for_chart("voltage")
|
||||||
|
assert "voltage" in result
|
||||||
|
assert "volts" in result
|
||||||
|
end
|
||||||
|
|
||||||
|
test "traffic returns nil" do
|
||||||
|
assert nil == Show.get_sensor_types_for_chart("traffic")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown sensor type returns itself in a list" do
|
||||||
|
assert ["count"] == Show.get_sensor_types_for_chart("count")
|
||||||
|
assert ["pppoe_sessions"] == Show.get_sensor_types_for_chart("pppoe_sessions")
|
||||||
|
assert ["connections"] == Show.get_sensor_types_for_chart("connections")
|
||||||
|
assert ["my_custom_type"] == Show.get_sensor_types_for_chart("my_custom_type")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "calculate_chart_stats/1" do
|
||||||
|
test "returns {nil, nil} for nil input" do
|
||||||
|
assert {nil, nil} == Show.calculate_chart_stats(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes max and min from chart data" do
|
||||||
|
chart_data =
|
||||||
|
Jason.encode!(%{
|
||||||
|
datasets: [
|
||||||
|
%{data: [%{y: 10}, %{y: 20}, %{y: 5}]}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {20, 5} == Show.calculate_chart_stats(chart_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles multiple datasets" do
|
||||||
|
chart_data =
|
||||||
|
Jason.encode!(%{
|
||||||
|
datasets: [
|
||||||
|
%{data: [%{y: 100}, %{y: 200}]},
|
||||||
|
%{data: [%{y: 50}, %{y: 300}]}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {300, 50} == Show.calculate_chart_stats(chart_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {nil, nil} for empty datasets" do
|
||||||
|
chart_data = Jason.encode!(%{datasets: [%{data: []}]})
|
||||||
|
assert {nil, nil} == Show.calculate_chart_stats(chart_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {nil, nil} for invalid JSON" do
|
||||||
|
assert {nil, nil} == Show.calculate_chart_stats("not valid json")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {nil, nil} for JSON with missing datasets key" do
|
||||||
|
assert {nil, nil} == Show.calculate_chart_stats(~s({"foo": "bar"}))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles nil y values by skipping them" do
|
||||||
|
chart_data =
|
||||||
|
Jason.encode!(%{
|
||||||
|
datasets: [
|
||||||
|
%{data: [%{y: 10}, %{y: nil}, %{y: 30}]}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {30, 10} == Show.calculate_chart_stats(chart_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles single data point" do
|
||||||
|
chart_data =
|
||||||
|
Jason.encode!(%{
|
||||||
|
datasets: [
|
||||||
|
%{data: [%{y: 42}]}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {42, 42} == Show.calculate_chart_stats(chart_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "humanize_sensor_type/1" do
|
||||||
|
test "converts snake_case to capitalized words" do
|
||||||
|
assert "Cpu Load" == Show.humanize_sensor_type("cpu_load")
|
||||||
|
assert "My Custom Type" == Show.humanize_sensor_type("my_custom_type")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles single word" do
|
||||||
|
assert "Temperature" == Show.humanize_sensor_type("temperature")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles empty string" do
|
||||||
|
assert "" == Show.humanize_sensor_type("")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles already uppercase" do
|
||||||
|
assert "Cpu Temperature" == Show.humanize_sensor_type("cpu_temperature")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build_graph_params/2" do
|
||||||
|
test "builds params with just range when no filters present" do
|
||||||
|
assigns = %{range: "24h", interface_id: nil, sensor_id: nil, storage_id: nil}
|
||||||
|
assert %{"range" => "24h"} == Show.build_graph_params(assigns, "24h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds params with interface_id when present" do
|
||||||
|
assigns = %{range: "1h", interface_id: "iface-1", sensor_id: nil, storage_id: nil}
|
||||||
|
assert %{"range" => "1h", "interface_id" => "iface-1"} == Show.build_graph_params(assigns, "1h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds params with sensor_id when present" do
|
||||||
|
assigns = %{range: "6h", interface_id: nil, sensor_id: "sensor-42", storage_id: nil}
|
||||||
|
assert %{"range" => "6h", "sensor_id" => "sensor-42"} == Show.build_graph_params(assigns, "6h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds params with storage_id when present" do
|
||||||
|
assigns = %{range: "7d", interface_id: nil, sensor_id: nil, storage_id: "storage-5"}
|
||||||
|
assert %{"range" => "7d", "storage_id" => "storage-5"} == Show.build_graph_params(assigns, "7d")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds params with all filters when present" do
|
||||||
|
assigns = %{
|
||||||
|
range: "30d",
|
||||||
|
interface_id: "iface-1",
|
||||||
|
sensor_id: "sensor-42",
|
||||||
|
storage_id: "storage-5"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert %{"range" => "30d", "interface_id" => "iface-1", "sensor_id" => "sensor-42", "storage_id" => "storage-5"} ==
|
||||||
|
Show.build_graph_params(assigns, "30d")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses custom range override" do
|
||||||
|
assigns = %{range: "24h", interface_id: nil, sensor_id: nil, storage_id: nil}
|
||||||
|
assert %{"range" => "1h"} == Show.build_graph_params(assigns, "1h")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do
|
defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do
|
||||||
use ToweropsWeb.ConnCase, async: false
|
use ToweropsWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
alias Towerops.Accounts.BrowserSession
|
||||||
alias ToweropsWeb.Plugs.UpdateSessionActivity
|
alias ToweropsWeb.Plugs.UpdateSessionActivity
|
||||||
|
|
||||||
describe "init/1" do
|
describe "init/1" do
|
||||||
|
|
@ -29,7 +30,6 @@ defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "starts background task when user_token and live_socket_id present", %{conn: conn} do
|
test "starts background task when user_token and live_socket_id present", %{conn: conn} do
|
||||||
# Exercises the get_session/2 call for :live_socket_id within call/2.
|
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> init_test_session(%{
|
|> init_test_session(%{
|
||||||
|
|
@ -41,4 +41,90 @@ defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do
|
||||||
assert conn.state == :unset
|
assert conn.state == :unset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "session_timed_out?/1" do
|
||||||
|
test "returns true when session has been inactive for more than 30 minutes" do
|
||||||
|
old_time = DateTime.add(DateTime.utc_now(), -31, :minute)
|
||||||
|
session = %{last_activity_at: old_time}
|
||||||
|
|
||||||
|
assert UpdateSessionActivity.session_timed_out?(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false when session was recently active" do
|
||||||
|
recent_time = DateTime.add(DateTime.utc_now(), -5, :second)
|
||||||
|
session = %{last_activity_at: recent_time}
|
||||||
|
|
||||||
|
refute UpdateSessionActivity.session_timed_out?(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses inserted_at when last_activity_at is nil" do
|
||||||
|
old_time = DateTime.add(DateTime.utc_now(), -31, :minute)
|
||||||
|
session = %{last_activity_at: nil, inserted_at: old_time}
|
||||||
|
|
||||||
|
assert UpdateSessionActivity.session_timed_out?(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false for recent inserted_at when last_activity_at is nil" do
|
||||||
|
recent_time = DateTime.add(DateTime.utc_now(), -5, :second)
|
||||||
|
session = %{last_activity_at: nil, inserted_at: recent_time}
|
||||||
|
|
||||||
|
refute UpdateSessionActivity.session_timed_out?(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false at exactly 30 minutes of inactivity" do
|
||||||
|
borderline_time = DateTime.add(DateTime.utc_now(), -30, :minute)
|
||||||
|
session = %{last_activity_at: borderline_time}
|
||||||
|
|
||||||
|
refute UpdateSessionActivity.session_timed_out?(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns true just over 30 minutes of inactivity" do
|
||||||
|
old_time = DateTime.add(DateTime.utc_now(), -30, :minute)
|
||||||
|
old_time = DateTime.add(old_time, -1, :second)
|
||||||
|
session = %{last_activity_at: old_time}
|
||||||
|
|
||||||
|
assert UpdateSessionActivity.session_timed_out?(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_session_activity/2" do
|
||||||
|
test "returns :ok when no session found for token" do
|
||||||
|
assert :ok == UpdateSessionActivity.handle_session_activity("nonexistent-token", nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :ok when session found and not timed out" do
|
||||||
|
user = Towerops.AccountsFixtures.user_fixture()
|
||||||
|
{_token, user_token} = Towerops.Accounts.generate_user_session_token_with_record(user)
|
||||||
|
|
||||||
|
%BrowserSession{}
|
||||||
|
|> BrowserSession.create_changeset(%{
|
||||||
|
user_id: user.id,
|
||||||
|
user_token_id: user_token.id,
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
last_activity_at: DateTime.utc_now(),
|
||||||
|
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
|
||||||
|
})
|
||||||
|
|> Towerops.Repo.insert!()
|
||||||
|
|
||||||
|
assert :ok == UpdateSessionActivity.handle_session_activity(user_token.id, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "revoke_inactive_session/2" do
|
||||||
|
test "returns nil when no live_socket_id" do
|
||||||
|
user = Towerops.AccountsFixtures.user_fixture()
|
||||||
|
token = Towerops.Accounts.generate_user_session_token(user)
|
||||||
|
|
||||||
|
assert nil == UpdateSessionActivity.revoke_inactive_session(token, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "broadcasts disconnect when live_socket_id present" do
|
||||||
|
user = Towerops.AccountsFixtures.user_fixture()
|
||||||
|
token = Towerops.Accounts.generate_user_session_token(user)
|
||||||
|
|
||||||
|
ToweropsWeb.Endpoint.subscribe("users_sessions:test-session")
|
||||||
|
assert :ok == UpdateSessionActivity.revoke_inactive_session(token, "users_sessions:test-session")
|
||||||
|
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: "users_sessions:test-session"}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,90 @@ defmodule ToweropsWeb.RemoteIpTest do
|
||||||
|
|
||||||
alias ToweropsWeb.RemoteIp
|
alias ToweropsWeb.RemoteIp
|
||||||
|
|
||||||
test "extracts the first forwarded IP from conn headers", %{conn: conn} do
|
describe "from_conn/1" do
|
||||||
conn =
|
test "extracts the first forwarded IP when from a trusted proxy (10.x)", %{conn: conn} do
|
||||||
conn
|
conn =
|
||||||
|> put_req_header("x-forwarded-for", "203.0.113.10, 10.0.0.1")
|
conn
|
||||||
|> put_req_header("x-real-ip", "198.51.100.1")
|
|> Map.put(:remote_ip, {10, 0, 0, 1})
|
||||||
|
|> put_req_header("x-forwarded-for", "203.0.113.10, 10.0.0.1")
|
||||||
|
|
||||||
assert RemoteIp.from_conn(conn) == "203.0.113.10"
|
assert RemoteIp.from_conn(conn) == "203.0.113.10"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extracts the first forwarded IP when from a trusted proxy (172.16-31.x)", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Map.put(:remote_ip, {172, 16, 0, 1})
|
||||||
|
|> put_req_header("x-forwarded-for", "198.51.100.1, 172.16.0.1")
|
||||||
|
|
||||||
|
assert RemoteIp.from_conn(conn) == "198.51.100.1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extracts the first forwarded IP when from a trusted proxy (192.168.x)", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Map.put(:remote_ip, {192, 168, 1, 1})
|
||||||
|
|> put_req_header("x-forwarded-for", "198.51.100.1")
|
||||||
|
|
||||||
|
assert RemoteIp.from_conn(conn) == "198.51.100.1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses x-real-ip when x-forwarded-for is missing and from trusted proxy", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Map.put(:remote_ip, {10, 0, 0, 1})
|
||||||
|
|> put_req_header("x-real-ip", "198.51.100.1")
|
||||||
|
|
||||||
|
assert RemoteIp.from_conn(conn) == "198.51.100.1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses remote_ip directly when neither forwarded header is present and from trusted proxy",
|
||||||
|
%{conn: conn} do
|
||||||
|
conn = Map.put(conn, :remote_ip, {10, 0, 0, 1})
|
||||||
|
|
||||||
|
assert RemoteIp.from_conn(conn) == "10.0.0.1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses remote_ip directly when NOT from a trusted proxy", %{conn: conn} do
|
||||||
|
# 8.8.8.8 is a public IP, not a trusted proxy — x-forwarded-for is ignored
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Map.put(:remote_ip, {8, 8, 8, 8})
|
||||||
|
|> put_req_header("x-forwarded-for", "203.0.113.10")
|
||||||
|
|
||||||
|
assert RemoteIp.from_conn(conn) == "8.8.8.8"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "trims whitespace from forwarded IP", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Map.put(:remote_ip, {10, 0, 0, 1})
|
||||||
|
|> put_req_header("x-forwarded-for", " 203.0.113.10 , 10.0.0.1")
|
||||||
|
|
||||||
|
assert RemoteIp.from_conn(conn) == "203.0.113.10"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "formats IPv6 addresses in uppercase hex" do
|
describe "format/1" do
|
||||||
assert RemoteIp.format({8193, 3512, 0, 0, 0, 0, 0, 1}) == "2001:DB8:0:0:0:0:0:1"
|
test "formats IPv4 tuple" do
|
||||||
|
assert RemoteIp.format({192, 168, 1, 1}) == "192.168.1.1"
|
||||||
|
assert RemoteIp.format({10, 0, 0, 1}) == "10.0.0.1"
|
||||||
|
assert RemoteIp.format({0, 0, 0, 0}) == "0.0.0.0"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "formats IPv6 addresses in uppercase hex" do
|
||||||
|
assert RemoteIp.format({8193, 3512, 0, 0, 0, 0, 0, 1}) == "2001:DB8:0:0:0:0:0:1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "formats loopback IPv6" do
|
||||||
|
assert RemoteIp.format({0, 0, 0, 0, 0, 0, 0, 1}) == "0:0:0:0:0:0:0:1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for invalid tuple" do
|
||||||
|
assert RemoteIp.format(:error) == nil
|
||||||
|
assert RemoteIp.format("not-a-tuple") == nil
|
||||||
|
assert RemoteIp.format(nil) == nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "from_socket/1" do
|
describe "from_socket/1" do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue