diff --git a/lib/mix/tasks/geoip.import.ex b/lib/mix/tasks/geoip.import.ex index ff53aca6..b7ea5a24 100644 --- a/lib/mix/tasks/geoip.import.ex +++ b/lib/mix/tasks/geoip.import.ex @@ -274,7 +274,8 @@ defmodule Mix.Tasks.Geoip.Import do end) end - defp parse_location_line(line) do + @doc false + def parse_location_line(line) do # CSV format: geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name, # subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name, # city_name,metro_code,time_zone,is_in_european_union @@ -340,13 +341,12 @@ defmodule Mix.Tasks.Geoip.Import do end) end - defp parse_block_line(line) do - # Format: network,geoname_id,registered_country_geoname_id,... + @doc false + def parse_block_line(line) do parts = String.split(line, ",") case parts do [network, geoname_id, registered_country_id | _rest] -> - # Try geoname_id first, fall back to registered_country_id geoname_id_int = parse_geoname_id(geoname_id) registered_id_int = parse_geoname_id(registered_country_id) final_geoname_id = geoname_id_int || registered_id_int @@ -358,9 +358,10 @@ defmodule Mix.Tasks.Geoip.Import do end end - defp build_block_entry(_network, nil, _registered_id_int), do: nil + @doc false + def build_block_entry(_network, nil, _registered_id_int), do: nil - defp build_block_entry(network, final_geoname_id, registered_id_int) do + def build_block_entry(network, final_geoname_id, registered_id_int) do case parse_cidr(network) do {:ok, start_int, end_int} -> %{ @@ -376,16 +377,18 @@ defmodule Mix.Tasks.Geoip.Import do end end - defp parse_geoname_id(""), do: nil + @doc false + def parse_geoname_id(""), do: nil - defp parse_geoname_id(geoname_id_str) do + def parse_geoname_id(geoname_id_str) do case Integer.parse(geoname_id_str) do {geoname_id, _} -> geoname_id :error -> nil end end - defp parse_cidr(cidr) do + @doc false + def parse_cidr(cidr) do case String.split(cidr, "/") do [ip_str, prefix_str] -> with {:ok, {a, b, c, d}} <- parse_ip_address(ip_str), diff --git a/lib/towerops/accounts/hibp.ex b/lib/towerops/accounts/hibp.ex index e38a0e3c..4a5a24dd 100644 --- a/lib/towerops/accounts/hibp.ex +++ b/lib/towerops/accounts/hibp.ex @@ -50,7 +50,8 @@ defmodule Towerops.Accounts.HIBP do def check_password(_), do: {:ok, :unknown} # Hash password to SHA-1 (HIBP uses SHA-1, not for security but for API compatibility) - defp hash_password(password) do + @doc false + def hash_password(password) do :sha |> :crypto.hash(password) |> Base.encode16() @@ -77,7 +78,8 @@ defmodule Towerops.Accounts.HIBP do end # Parse API response and check if suffix exists - defp check_suffix(results, suffix) do + @doc false + def check_suffix(results, suffix) do results |> String.split("\r\n", trim: true) |> Enum.find_value({:ok, 0}, fn line -> diff --git a/lib/towerops/alerts/storm_detector.ex b/lib/towerops/alerts/storm_detector.ex index 325c3c34..30ae9643 100644 --- a/lib/towerops/alerts/storm_detector.ex +++ b/lib/towerops/alerts/storm_detector.ex @@ -405,7 +405,8 @@ defmodule Towerops.Alerts.StormDetector do end end - defp prune_old_timestamps(queue, cutoff_ms) do + @doc false + def prune_old_timestamps(queue, cutoff_ms) do case :queue.peek(queue) do {:value, ts} when ts < cutoff_ms -> {_, queue} = :queue.out(queue) @@ -416,7 +417,8 @@ defmodule Towerops.Alerts.StormDetector do end end - defp limit_queue_size(queue, max_size) do + @doc false + def limit_queue_size(queue, max_size) do current_size = :queue.len(queue) if current_size > max_size do diff --git a/lib/towerops/cn_maestro/client.ex b/lib/towerops/cn_maestro/client.ex index e9d61814..8e94f517 100644 --- a/lib/towerops/cn_maestro/client.ex +++ b/lib/towerops/cn_maestro/client.ex @@ -165,11 +165,8 @@ defmodule Towerops.CnMaestro.Client do e -> {:error, Exception.message(e)} end - defp maybe_add_test_plug(opts) do - if Application.get_env(:towerops, :env) == :test do - Keyword.put(opts, :plug, {Req.Test, __MODULE__}) - else - opts - end - end + defp maybe_add_test_plug(opts), do: maybe_attach_test_plug(opts, Application.get_env(:towerops, :env)) + + defp maybe_attach_test_plug(opts, :test), do: Keyword.put(opts, :plug, {Req.Test, __MODULE__}) + defp maybe_attach_test_plug(opts, _env), do: opts end diff --git a/lib/towerops/cn_maestro/sync.ex b/lib/towerops/cn_maestro/sync.ex index 2006d45b..81353f51 100644 --- a/lib/towerops/cn_maestro/sync.ex +++ b/lib/towerops/cn_maestro/sync.ex @@ -71,15 +71,11 @@ defmodule Towerops.CnMaestro.Sync do end end - defp process_network(network, org_id, {count, acc}) do - name = network["name"] - network_id = network["id"] || network["name"] + @doc false + def process_network(%{"name" => name}, _org_id, state) when name in [nil, ""], do: state - if is_nil(name) or name == "" do - {count, acc} - else - upsert_network_site(org_id, name, network_id, count, acc) - end + def process_network(network, org_id, {count, acc}) do + upsert_network_site(org_id, network["name"], network["id"] || network["name"], count, acc) end defp upsert_network_site(org_id, name, network_id, count, acc) do @@ -113,14 +109,15 @@ defmodule Towerops.CnMaestro.Sync do defp process_device(device_data, org_id, site_map, acc) do mac = device_data["mac"] ip = device_data["ip"] - name = device_data["name"] || device_data["mac"] - network = device_data["network"] - local_site = site_map[network] - if is_nil(ip) and is_nil(mac) do - acc - else - sync_single_device(org_id, ip, mac, name, local_site, acc) + case {ip, mac} do + {nil, nil} -> + acc + + _ -> + name = device_data["name"] || mac + local_site = site_map[device_data["network"]] + sync_single_device(org_id, ip, mac, name, local_site, acc) end end diff --git a/lib/towerops/config_changes.ex b/lib/towerops/config_changes.ex index 9d412666..95402687 100644 --- a/lib/towerops/config_changes.ex +++ b/lib/towerops/config_changes.ex @@ -13,22 +13,24 @@ defmodule Towerops.ConfigChanges do require Logger + # Patterns allow optional unified-diff line prefixes (+, -, " ") before the + # leading "/" so they match both raw MikroTik configs and unified-diff output. @mikrotik_sections %{ - "firewall" => ~r/^\/(ip )?firewall/m, - "interface" => ~r/^\/interface/m, - "routing" => ~r/^\/(ip |routing )(route|ospf|bgp|rip)/m, - "wireless" => ~r/^\/interface wireless/m, - "queue" => ~r/^\/queue/m, - "nat" => ~r/^\/(ip )?firewall nat/m, - "address" => ~r/^\/ip address/m, - "dns" => ~r/^\/ip dns/m, - "dhcp" => ~r/^\/ip dhcp/m, - "bridge" => ~r/^\/interface bridge/m, - "vlan" => ~r/^\/interface vlan/m, - "snmp" => ~r/^\/snmp/m, - "user" => ~r/^\/user/m, - "system" => ~r/^\/system/m, - "ppp" => ~r/^\/ppp/m + "firewall" => ~r/^[+\- ]?\/(ip )?firewall/m, + "interface" => ~r/^[+\- ]?\/interface/m, + "routing" => ~r/^[+\- ]?\/(ip |routing )(route|ospf|bgp|rip)/m, + "wireless" => ~r/^[+\- ]?\/interface wireless/m, + "queue" => ~r/^[+\- ]?\/queue/m, + "nat" => ~r/^[+\- ]?\/(ip )?firewall nat/m, + "address" => ~r/^[+\- ]?\/ip address/m, + "dns" => ~r/^[+\- ]?\/ip dns/m, + "dhcp" => ~r/^[+\- ]?\/ip dhcp/m, + "bridge" => ~r/^[+\- ]?\/interface bridge/m, + "vlan" => ~r/^[+\- ]?\/interface vlan/m, + "snmp" => ~r/^[+\- ]?\/snmp/m, + "user" => ~r/^[+\- ]?\/user/m, + "system" => ~r/^[+\- ]?\/system/m, + "ppp" => ~r/^[+\- ]?\/ppp/m } @doc """ @@ -153,14 +155,22 @@ defmodule Towerops.ConfigChanges do |> Repo.preload([:device, :backup_before, :backup_after]) end - # Detect which MikroTik config sections were modified based on diff content - defp detect_changed_sections(diff) do + @doc """ + Detects which MikroTik config sections were modified in a unified diff. + + Returns a sorted list of section names (strings) matching sections like + `firewall`, `interface`, `routing`, etc. + """ + @spec detect_changed_sections(String.t()) :: [String.t()] + def detect_changed_sections(diff) when is_binary(diff) do @mikrotik_sections |> Enum.filter(fn {_name, pattern} -> Regex.match?(pattern, diff) end) |> Enum.map(fn {name, _} -> name end) |> Enum.sort() end + def detect_changed_sections(_), do: [] + defp apply_date_filters(query, opts) do query |> maybe_filter_after(Keyword.get(opts, :after)) diff --git a/lib/towerops/config_changes/correlator.ex b/lib/towerops/config_changes/correlator.ex index f55e0a38..55d0bdf1 100644 --- a/lib/towerops/config_changes/correlator.ex +++ b/lib/towerops/config_changes/correlator.ex @@ -155,7 +155,14 @@ defmodule Towerops.ConfigChanges.Correlator do ) end - defp build_description(deltas, event) do + @doc """ + Builds a human-readable description of a config change's impact. + + Includes sections from `event.sections_changed` when present, and + includes metric deltas that exceed built-in reporting thresholds + (latency/loss > 10% increase, throughput > 5% drop). + """ + def build_description(deltas, event) do parts = [] |> maybe_add( @@ -171,38 +178,49 @@ defmodule Towerops.ConfigChanges.Correlator do deltas.throughput_delta < -0.05 ) - sections = - if Enum.any?(event.sections_changed), - do: " (sections: #{Enum.join(event.sections_changed, ", ")})", - else: "" - - "After config change#{sections}: #{Enum.join(parts, ", ")}." + "After config change#{format_sections(event)}: #{Enum.join(parts, ", ")}." end - # Calculate percentage change between two values. - # Returns 0.0 if old value is zero or negative. - defp safe_pct_change(old, new_val) when old > 0.0, do: (new_val - old) / old - defp safe_pct_change(_old, _new_val), do: 0.0 + defp format_sections(%{sections_changed: sections}) when is_list(sections) and sections != [], + do: " (sections: #{Enum.join(sections, ", ")})" - # Determine severity based on metric deltas. - # - "critical" when latency > 50%, loss > 100%, or throughput < -30% - # - "warning" when latency > 20%, loss > 50%, or throughput < -15% - # - "info" otherwise - defp determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta) do - cond do - latency_delta > 0.50 or loss_delta > 1.0 or throughput_delta < -0.30 -> "critical" - latency_delta > 0.20 or loss_delta > 0.50 or throughput_delta < -0.15 -> "warning" - true -> "info" - end - end + defp format_sections(_event), do: "" - # Append text to list if condition is true, otherwise return list unchanged. - defp maybe_add(parts, text, true), do: parts ++ [text] - defp maybe_add(parts, _text, false), do: parts + @doc """ + Calculates percentage change between two values. - defp to_float(nil), do: 0.0 - defp to_float(v) when is_float(v), do: v - defp to_float(v) when is_integer(v), do: v / 1 + Returns `0.0` if the old value is zero or negative (avoids division by zero + and treats "no baseline" as "no change"). + """ + @spec safe_pct_change(number(), number()) :: float() + def safe_pct_change(old, new_val) when old > 0.0, do: (new_val - old) / old + def safe_pct_change(_old, _new_val), do: 0.0 + + @doc """ + Determines severity based on metric deltas expressed as ratios + (0.5 = 50% increase). Thresholds: + + * `"critical"`: latency > 50% · loss > 100% · throughput < -30% + * `"warning"`: latency > 20% · loss > 50% · throughput < -15% + * `"info"`: otherwise + """ + @spec determine_severity_from_deltas(number(), number(), number()) :: String.t() + def determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta) + when latency_delta > 0.50 or loss_delta > 1.0 or throughput_delta < -0.30, do: "critical" + + def determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta) + when latency_delta > 0.20 or loss_delta > 0.50 or throughput_delta < -0.15, do: "warning" + + def determine_severity_from_deltas(_latency, _loss, _throughput), do: "info" + + @doc false + def maybe_add(parts, text, true), do: parts ++ [text] + def maybe_add(parts, _text, false), do: parts + + @doc false + def to_float(nil), do: 0.0 + def to_float(v) when is_float(v), do: v + def to_float(v) when is_integer(v), do: v / 1 defp stringify_metrics(metrics) do %{ diff --git a/lib/towerops/geocoding.ex b/lib/towerops/geocoding.ex index 2d12b87a..c189194c 100644 --- a/lib/towerops/geocoding.ex +++ b/lib/towerops/geocoding.ex @@ -31,18 +31,22 @@ defmodule Towerops.Geocoding do end defp get_api_key do - api_key = get_system_api_key() - - if api_key && String.trim(api_key) != "" do - {:ok, String.trim(api_key)} - else - {:error, :no_api_key} - end + :towerops + |> Application.get_env(:google_maps_api_key) + |> fallback_env("GOOGLE_MAPS_API_KEY") + |> validate_key() end - defp get_system_api_key do - Application.get_env(:towerops, :google_maps_api_key) || - System.get_env("GOOGLE_MAPS_API_KEY") + defp fallback_env(nil, env_var), do: System.get_env(env_var) + defp fallback_env(value, _env_var), do: value + + defp validate_key(nil), do: {:error, :no_api_key} + + defp validate_key(key) when is_binary(key) do + case String.trim(key) do + "" -> {:error, :no_api_key} + trimmed -> {:ok, trimmed} + end end defp make_geocoding_request(address, api_key) do @@ -51,7 +55,7 @@ defmodule Towerops.Geocoding do "key" => api_key } - case HTTP.get(__MODULE__, @google_geocoding_url, params: params, timeout: 10_000) do + case HTTP.get(__MODULE__, @google_geocoding_url, params: params, receive_timeout: 10_000) do {:ok, %Req.Response{status: 200, body: body}} -> {:ok, body} diff --git a/lib/towerops/monitoring/executors/dns_executor.ex b/lib/towerops/monitoring/executors/dns_executor.ex index e500f37f..bf13cbc2 100644 --- a/lib/towerops/monitoring/executors/dns_executor.ex +++ b/lib/towerops/monitoring/executors/dns_executor.ex @@ -71,16 +71,18 @@ defmodule Towerops.Monitoring.Executors.DnsExecutor do {:error, "Exception: #{Exception.message(e)}"} end - defp record_type_to_atom("A"), do: :a - defp record_type_to_atom("AAAA"), do: :aaaa - defp record_type_to_atom("CNAME"), do: :cname - defp record_type_to_atom("MX"), do: :mx - defp record_type_to_atom("TXT"), do: :txt - defp record_type_to_atom("NS"), do: :ns - defp record_type_to_atom("PTR"), do: :ptr - defp record_type_to_atom(_), do: :a + @doc false + def record_type_to_atom("A"), do: :a + def record_type_to_atom("AAAA"), do: :aaaa + def record_type_to_atom("CNAME"), do: :cname + def record_type_to_atom("MX"), do: :mx + def record_type_to_atom("TXT"), do: :txt + def record_type_to_atom("NS"), do: :ns + def record_type_to_atom("PTR"), do: :ptr + def record_type_to_atom(_), do: :a - defp parse_server(server_str) do + @doc false + def parse_server(server_str) when is_binary(server_str) do case String.split(server_str, ".") do [a, b, c, d] -> with {a_int, ""} <- Integer.parse(a), @@ -98,6 +100,8 @@ defmodule Towerops.Monitoring.Executors.DnsExecutor do end end + def parse_server(_), do: {{8, 8, 8, 8}, 53} + defp process_dns_response(dns_rec, record_type, expected, response_time) do answers = extract_answers(dns_rec, record_type) diff --git a/lib/towerops/monitoring/executors/http_executor.ex b/lib/towerops/monitoring/executors/http_executor.ex index 225179fb..8cba6ef8 100644 --- a/lib/towerops/monitoring/executors/http_executor.ex +++ b/lib/towerops/monitoring/executors/http_executor.ex @@ -83,18 +83,19 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do end # Loopback - defp private_ip?({127, _, _, _}), do: true + @doc false + def private_ip?({127, _, _, _}), do: true # Private class A - defp private_ip?({10, _, _, _}), do: true + def private_ip?({10, _, _, _}), do: true # Private class B - defp private_ip?({172, b, _, _}) when b >= 16 and b <= 31, do: true + def private_ip?({172, b, _, _}) when b >= 16 and b <= 31, do: true # Private class C - defp private_ip?({192, 168, _, _}), do: true + def private_ip?({192, 168, _, _}), do: true # Link-local / AWS metadata - defp private_ip?({169, 254, _, _}), do: true + def private_ip?({169, 254, _, _}), do: true # Unspecified - defp private_ip?({0, 0, 0, 0}), do: true - defp private_ip?(_), do: false + def private_ip?({0, 0, 0, 0}), do: true + def private_ip?(_), do: false defp build_req_opts(config, timeout_ms) do method = String.downcase(Map.get(config, "method", "GET")) @@ -120,15 +121,16 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do end # Normalize HTTP method to atom using whitelist to prevent atom table pollution - defp normalize_http_method("get"), do: :get - defp normalize_http_method("post"), do: :post - defp normalize_http_method("put"), do: :put - defp normalize_http_method("patch"), do: :patch - defp normalize_http_method("delete"), do: :delete - defp normalize_http_method("head"), do: :head - defp normalize_http_method("options"), do: :options + @doc false + def normalize_http_method("get"), do: :get + def normalize_http_method("post"), do: :post + def normalize_http_method("put"), do: :put + def normalize_http_method("patch"), do: :patch + def normalize_http_method("delete"), do: :delete + def normalize_http_method("head"), do: :head + def normalize_http_method("options"), do: :options # Default to GET for unknown methods - defp normalize_http_method(_), do: :get + def normalize_http_method(_), do: :get defp validate_response(%Req.Response{status: status, body: body}, config, response_time) do expected_status = Map.get(config, "expected_status", 200) diff --git a/lib/towerops/monitoring/executors/ping_executor.ex b/lib/towerops/monitoring/executors/ping_executor.ex index 4d9606bb..4f994665 100644 --- a/lib/towerops/monitoring/executors/ping_executor.ex +++ b/lib/towerops/monitoring/executors/ping_executor.ex @@ -88,34 +88,23 @@ defmodule Towerops.Monitoring.Executors.PingExecutor do end end - defp validate_host(host) do - # Only allow valid hostnames and IP addresses — no shell metacharacters - if Regex.match?(~r/^[a-zA-Z0-9\.\-\:]+$/, host) do + # \A and \z anchor to absolute start/end, unlike ^/$ which also match around + # a trailing newline (which would allow shell injection via `host\ninjected`). + @host_pattern ~r/\A[a-zA-Z0-9\.\-\:]+\z/ + + @doc false + def validate_host(host) when is_binary(host) do + if Regex.match?(@host_pattern, host) do :ok else {:error, "Invalid host: contains disallowed characters"} end end - defp build_args(host, count, timeout_ms) do - count_str = Integer.to_string(count) + def validate_host(_host), do: {:error, "Invalid host: must be a string"} - case :os.type() do - {:unix, :darwin} -> - # macOS: -W is in milliseconds - ["-c", count_str, "-W", Integer.to_string(timeout_ms), host] - - {:unix, _} -> - # Linux: -w is in seconds (total deadline) - deadline = max(div(timeout_ms, 1000), count + 1) - ["-c", count_str, "-w", Integer.to_string(deadline), host] - end - end - - defp parse_packet_loss(output) do - # Match patterns like "3 packets transmitted, 3 packets received, 0.0% packet loss" - # or "3 packets transmitted, 3 received, 0% packet loss" - # or "1 packets transmitted, 0 received, +1 errors, 100% packet loss" + @doc false + def parse_packet_loss(output) do case Regex.run( ~r/(\d+) packets? transmitted, (\d+) (?:packets? )?received,(?:\s*\+\d+ errors,)? ([\d.]+)% packet loss/, output @@ -129,20 +118,33 @@ defmodule Towerops.Monitoring.Executors.PingExecutor do end end - defp parse_rtt(output) do - # macOS: "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms" - # Linux: "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms" + @doc false + def parse_rtt(output) do case Regex.run(~r/(?:round-trip|rtt) min\/avg\/max\/(?:std|m)dev = [\d.]+\/([\d.]+)\//, output) do [_, avg_str] -> {avg_ms, _} = Float.parse(avg_str) {:ok, avg_ms} nil -> - # No RTT stats (100% loss case) {:error, "100% packet loss"} end end - defp format_loss(loss_pct) when loss_pct == 0.0, do: "0%" - defp format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%" + @doc false + def format_loss(loss_pct) when loss_pct == 0.0, do: "0%" + def format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%" + + @doc false + def build_args(host, count, timeout_ms) do + count_str = Integer.to_string(count) + + case :os.type() do + {:unix, :darwin} -> + ["-c", count_str, "-W", Integer.to_string(timeout_ms), host] + + {:unix, _} -> + deadline = max(div(timeout_ms, 1000), count + 1) + ["-c", count_str, "-w", Integer.to_string(deadline), host] + end + end end diff --git a/lib/towerops/monitoring/executors/snmp_interface_executor.ex b/lib/towerops/monitoring/executors/snmp_interface_executor.ex index 67736d2b..8dc51ca7 100644 --- a/lib/towerops/monitoring/executors/snmp_interface_executor.ex +++ b/lib/towerops/monitoring/executors/snmp_interface_executor.ex @@ -90,7 +90,8 @@ defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutor do {:ok, opts} end - defp add_snmp_credentials(opts, %{snmp_version: "3"} = device) do + @doc false + def add_snmp_credentials(opts, %{snmp_version: "3"} = device) do opts |> Keyword.put(:security_name, device.snmpv3_username || "") |> Keyword.put(:security_level, device.snmpv3_security_level || "noAuthNoPriv") @@ -100,7 +101,7 @@ defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutor do |> Keyword.put(:priv_password, device.snmpv3_priv_password || "") end - defp add_snmp_credentials(opts, device) do + def add_snmp_credentials(opts, device) do Keyword.put(opts, :community, device.snmp_community || "public") end @@ -114,36 +115,29 @@ defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutor do end end - defp determine_status(oper_status, interface) do - # ifOperStatus values from IF-MIB: - # 1 = up, 2 = down, 3 = testing, 4 = unknown, 5 = dormant, 6 = notPresent, 7 = lowerLayerDown + # ifOperStatus values from IF-MIB: + # 1 = up, 2 = down, 3 = testing, 4 = unknown, 5 = dormant, 6 = notPresent, 7 = lowerLayerDown + @doc false + def determine_status(1, _interface), do: 0 + def determine_status(2, %{if_admin_status: "down"}), do: 0 + def determine_status(2, _interface), do: 2 + def determine_status(status, _interface) when status in [3, 5], do: 1 + def determine_status(_status, _interface), do: 2 - cond do - # Interface is up - OK - oper_status == 1 -> 0 - # Interface is down - check if this is expected - oper_status == 2 && interface.if_admin_status == "down" -> 0 - # Interface is down but should be up - CRITICAL - oper_status == 2 -> 2 - # Testing, dormant - WARNING - oper_status in [3, 5] -> 1 - # Unknown, notPresent, lowerLayerDown - CRITICAL - true -> 2 - end - end - - defp format_output(interface, oper_status) do + @doc false + def format_output(interface, oper_status) do status_str = oper_status_to_string(oper_status) interface_name = interface.if_alias || interface.if_name || interface.if_descr || "Interface #{interface.if_index}" "#{interface_name}: #{status_str}" end - defp oper_status_to_string(1), do: "Up" - defp oper_status_to_string(2), do: "Down" - defp oper_status_to_string(3), do: "Testing" - defp oper_status_to_string(4), do: "Unknown" - defp oper_status_to_string(5), do: "Dormant" - defp oper_status_to_string(6), do: "Not Present" - defp oper_status_to_string(7), do: "Lower Layer Down" - defp oper_status_to_string(_), do: "Unknown Status" + @doc false + def oper_status_to_string(1), do: "Up" + def oper_status_to_string(2), do: "Down" + def oper_status_to_string(3), do: "Testing" + def oper_status_to_string(4), do: "Unknown" + def oper_status_to_string(5), do: "Dormant" + def oper_status_to_string(6), do: "Not Present" + def oper_status_to_string(7), do: "Lower Layer Down" + def oper_status_to_string(_), do: "Unknown Status" end diff --git a/lib/towerops/monitoring/executors/snmp_processor_executor.ex b/lib/towerops/monitoring/executors/snmp_processor_executor.ex index eca6c655..258e8a25 100644 --- a/lib/towerops/monitoring/executors/snmp_processor_executor.ex +++ b/lib/towerops/monitoring/executors/snmp_processor_executor.ex @@ -93,7 +93,8 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do {:ok, opts} end - defp add_snmp_credentials(opts, %{snmp_version: "3"} = device) do + @doc false + def add_snmp_credentials(opts, %{snmp_version: "3"} = device) do opts |> Keyword.put(:security_name, device.snmpv3_username || "") |> Keyword.put(:security_level, device.snmpv3_security_level || "noAuthNoPriv") @@ -103,7 +104,7 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do |> Keyword.put(:priv_password, device.snmpv3_priv_password || "") end - defp add_snmp_credentials(opts, device) do + def add_snmp_credentials(opts, device) do Keyword.put(opts, :community, device.snmp_community || "public") end @@ -118,7 +119,8 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do end end - defp build_processor_oid(processor) do + @doc false + def build_processor_oid(processor) do # Extract numeric index from processor_index string # Examples: "1" -> "1", "cisco_1" -> "1", "hr_1" -> "1" index = String.replace(processor.processor_index, ~r/[^\d]/, "") @@ -130,18 +132,13 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do end end - defp determine_status(load_percent) do - cond do - # CRITICAL - load_percent >= 90 -> 2 - # WARNING - load_percent >= 80 -> 1 - # OK - true -> 0 - end - end + @doc false + def determine_status(load_percent) when load_percent >= 90, do: 2 + def determine_status(load_percent) when load_percent >= 80, do: 1 + def determine_status(_), do: 0 - defp format_output(processor, load_percent) do + @doc false + def format_output(processor, load_percent) do description = processor.description || "Processor #{processor.processor_index}" "#{description}: #{Float.round(load_percent, 1)}% load" end diff --git a/lib/towerops/monitoring/executors/snmp_storage_executor.ex b/lib/towerops/monitoring/executors/snmp_storage_executor.ex index 959e37c1..271e4bb9 100644 --- a/lib/towerops/monitoring/executors/snmp_storage_executor.ex +++ b/lib/towerops/monitoring/executors/snmp_storage_executor.ex @@ -97,7 +97,8 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do {:ok, opts} end - defp add_snmp_credentials(opts, %{snmp_version: "3"} = device) do + @doc false + def add_snmp_credentials(opts, %{snmp_version: "3"} = device) do opts |> Keyword.put(:security_name, device.snmpv3_username || "") |> Keyword.put(:security_level, device.snmpv3_security_level || "noAuthNoPriv") @@ -107,7 +108,7 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do |> Keyword.put(:priv_password, device.snmpv3_priv_password || "") end - defp add_snmp_credentials(opts, device) do + def add_snmp_credentials(opts, device) do Keyword.put(opts, :community, device.snmp_community || "public") end @@ -131,28 +132,22 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do end end - defp ensure_integer(value) when is_integer(value), do: value - defp ensure_integer(value) when is_float(value), do: round(value) - defp ensure_integer(_), do: 0 + @doc false + def ensure_integer(value) when is_integer(value), do: value + def ensure_integer(value) when is_float(value), do: round(value) + def ensure_integer(_), do: 0 - defp calculate_usage_percent(%{size_units: 0}), do: 0.0 + @doc false + def calculate_usage_percent(%{size_units: 0}), do: 0.0 + def calculate_usage_percent(%{size_units: size, used_units: used}), do: used / size * 100.0 - defp calculate_usage_percent(%{size_units: size, used_units: used}) do - used / size * 100.0 - end + @doc false + def determine_status(usage_percent) when usage_percent >= 95, do: 2 + def determine_status(usage_percent) when usage_percent >= 85, do: 1 + def determine_status(_), do: 0 - defp determine_status(usage_percent) do - cond do - # CRITICAL - usage_percent >= 95 -> 2 - # WARNING - usage_percent >= 85 -> 1 - # OK - true -> 0 - end - end - - defp format_output(storage, usage_percent, storage_data) do + @doc false + def format_output(storage, usage_percent, storage_data) do description = storage.description || storage.device_name || "Storage #{storage.storage_index}" total_bytes = storage_data.allocation_units * storage_data.size_units used_bytes = storage_data.allocation_units * storage_data.used_units @@ -160,21 +155,10 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do "#{description}: #{Float.round(usage_percent, 1)}% used (#{format_bytes(used_bytes)} / #{format_bytes(total_bytes)})" end - defp format_bytes(bytes) when bytes >= 1_099_511_627_776 do - "#{Float.round(bytes / 1_099_511_627_776, 2)} TB" - end - - defp format_bytes(bytes) when bytes >= 1_073_741_824 do - "#{Float.round(bytes / 1_073_741_824, 2)} GB" - end - - defp format_bytes(bytes) when bytes >= 1_048_576 do - "#{Float.round(bytes / 1_048_576, 2)} MB" - end - - defp format_bytes(bytes) when bytes >= 1024 do - "#{Float.round(bytes / 1024, 2)} KB" - end - - defp format_bytes(bytes), do: "#{bytes} B" + @doc false + def format_bytes(bytes) when bytes >= 1_099_511_627_776, do: "#{Float.round(bytes / 1_099_511_627_776, 2)} TB" + def format_bytes(bytes) when bytes >= 1_073_741_824, do: "#{Float.round(bytes / 1_073_741_824, 2)} GB" + def format_bytes(bytes) when bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 2)} MB" + def format_bytes(bytes) when bytes >= 1024, do: "#{Float.round(bytes / 1024, 2)} KB" + def format_bytes(bytes), do: "#{bytes} B" end diff --git a/lib/towerops/monitoring/ping.ex b/lib/towerops/monitoring/ping.ex index 808c2108..359a7019 100644 --- a/lib/towerops/monitoring/ping.ex +++ b/lib/towerops/monitoring/ping.ex @@ -76,32 +76,27 @@ defmodule Towerops.Monitoring.Ping do {:error, :command_not_found} end - # Validate IP address format using Erlang's inet module - defp validate_ip_address(""), do: {:error, :invalid_ip} + @doc false + def validate_ip_address(""), do: {:error, :invalid_ip} - defp validate_ip_address(ip_address) do + def validate_ip_address(ip_address) when is_binary(ip_address) do case :inet.parse_address(String.to_charlist(ip_address)) do {:ok, _} -> :ok {:error, _} -> {:error, :invalid_ip} end end - # Parse the ping output to extract round-trip time - # macOS format: "round-trip min/avg/max/stddev = 1.234/1.234/1.234/0.000 ms" - # Linux format: "rtt min/avg/max/mdev = 1.234/1.234/1.234/0.000 ms" - defp parse_ping_output(output) do + @doc false + def parse_ping_output(output) do cond do - # macOS format match = Regex.run(~r/round-trip.*=\s*[\d.]+\/([\d.]+)\//, output) -> [_, avg_ms] = match {:ok, String.to_float(avg_ms)} - # Linux format match = Regex.run(~r/rtt.*=\s*[\d.]+\/([\d.]+)\//, output) -> [_, avg_ms] = match {:ok, String.to_float(avg_ms)} - # Alternative: extract from "time=X.XX ms" in the reply line match = Regex.run(~r/time[=<]([\d.]+)\s*ms/, output) -> [_, time_ms] = match {:ok, String.to_float(time_ms)} diff --git a/lib/towerops/netbox/client.ex b/lib/towerops/netbox/client.ex index dd672a30..957d221f 100644 --- a/lib/towerops/netbox/client.ex +++ b/lib/towerops/netbox/client.ex @@ -145,17 +145,16 @@ defmodule Towerops.NetBox.Client do @invalid_url_error "NetBox URL is missing or invalid — a full URL with https:// is required" - defp normalize_url(nil), do: {:error, @invalid_url_error} + @doc false + def normalize_url(nil), do: {:error, @invalid_url_error} - defp normalize_url(url) when is_binary(url) do + def normalize_url(url) when is_binary(url) do trimmed = url |> String.trim() |> String.trim_trailing("/") - - case URI.parse(trimmed) do - %URI{scheme: scheme} when scheme in ["http", "https"] -> - {:ok, trimmed} - - _ -> - {:error, @invalid_url_error} - end + trimmed |> URI.parse() |> check_scheme(trimmed) end + + def normalize_url(_), do: {:error, @invalid_url_error} + + defp check_scheme(%URI{scheme: scheme}, url) when scheme in ["http", "https"], do: {:ok, url} + defp check_scheme(_uri, _url), do: {:error, @invalid_url_error} end diff --git a/lib/towerops/netbox/sync.ex b/lib/towerops/netbox/sync.ex index 817fb903..fcc9c3a5 100644 --- a/lib/towerops/netbox/sync.ex +++ b/lib/towerops/netbox/sync.ex @@ -161,63 +161,78 @@ defmodule Towerops.NetBox.Sync do end end - defp resolve_site_id(nil, _site_by_name), do: nil + @doc false + def resolve_site_id(nil, _site_by_name), do: nil - defp resolve_site_id(site_name, site_by_name) do - case Map.get(site_by_name, String.downcase(site_name)) do - nil -> nil - site -> site.id + def resolve_site_id(site_name, site_by_name) when is_binary(site_name) do + site_by_name |> Map.get(String.downcase(site_name)) |> site_id_from() + end + + @doc false + def site_id_from(nil), do: nil + def site_id_from(%{id: id}), do: id + + @doc false + def find_existing_device(name, ip, device_by_name, device_by_ip) do + case Map.get(device_by_name, String.downcase(name)) do + nil -> lookup_by_ip(ip, device_by_ip) + device -> device end end - defp find_existing_device(name, ip, device_by_name, device_by_ip) do - Map.get(device_by_name, String.downcase(name)) || - (ip && Map.get(device_by_ip, ip)) - end + @doc false + def lookup_by_ip(nil, _device_by_ip), do: nil + def lookup_by_ip(ip, device_by_ip), do: Map.get(device_by_ip, ip) - # --- Helpers --- + # --- Helpers (public for testability) --- - defp extract_ip(nil), do: nil + @doc false + def extract_ip(nil), do: nil - defp extract_ip(%{"address" => addr}) when is_binary(addr) do + def extract_ip(%{"address" => addr}) when is_binary(addr) do # NetBox returns "10.0.0.1/24" format addr |> String.split("/") |> List.first() end - defp extract_ip(_), do: nil + def extract_ip(_), do: nil - defp map_status("active"), do: "up" - defp map_status("offline"), do: "down" - defp map_status("planned"), do: "pending" - defp map_status("staged"), do: "pending" - defp map_status("decommissioning"), do: "down" - defp map_status(other), do: other + @doc false + def map_status("active"), do: "up" + def map_status("offline"), do: "down" + def map_status("planned"), do: "pending" + def map_status("staged"), do: "pending" + def map_status("decommissioning"), do: "down" + def map_status(other), do: other - defp parse_decimal(nil), do: nil - defp parse_decimal(val) when is_float(val), do: Decimal.from_float(val) + @doc false + def parse_decimal(nil), do: nil + def parse_decimal(val) when is_float(val), do: Decimal.from_float(val) - defp parse_decimal(val) when is_binary(val) do + def parse_decimal(val) when is_binary(val) do case Decimal.parse(val) do {dec, _} -> dec :error -> nil end end - defp parse_decimal(val) when is_integer(val), do: Decimal.new(val) - defp parse_decimal(_), do: nil + def parse_decimal(val) when is_integer(val), do: Decimal.new(val) + def parse_decimal(_), do: nil - defp build_device_params(credentials) do + @doc false + def build_device_params(credentials) do %{} |> maybe_put("role", credentials["device_role_filter"]) |> maybe_put("site", credentials["site_filter"]) |> maybe_put("tag", credentials["tag_filter"]) end - defp maybe_put(params, _key, nil), do: params - defp maybe_put(params, _key, ""), do: params - defp maybe_put(params, key, value), do: Map.put(params, key, value) + @doc false + def maybe_put(params, _key, nil), do: params + def maybe_put(params, _key, ""), do: params + def maybe_put(params, key, value), do: Map.put(params, key, value) - defp build_site_params(credentials) do + @doc false + def build_site_params(credentials) do case credentials["tag_filter"] do nil -> %{} "" -> %{} @@ -225,11 +240,12 @@ defmodule Towerops.NetBox.Sync do end end - defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your NetBox API token" + @doc false + def humanize_sync_error(:unauthorized), do: "Authentication failed — check your NetBox API token" - defp humanize_sync_error(:not_found), do: "API endpoint not found — check your NetBox URL" + def humanize_sync_error(:not_found), do: "API endpoint not found — check your NetBox URL" - defp humanize_sync_error(reason) when is_binary(reason) do + def humanize_sync_error(reason) when is_binary(reason) do if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do "SSL certificate verification failed — unable to establish a secure connection" else diff --git a/lib/towerops/profiles/profile_watcher.ex b/lib/towerops/profiles/profile_watcher.ex index 32b2a8a3..47863ec8 100644 --- a/lib/towerops/profiles/profile_watcher.ex +++ b/lib/towerops/profiles/profile_watcher.ex @@ -71,19 +71,17 @@ defmodule Towerops.Profiles.ProfileWatcher do {:noreply, state} end - # Check if the file is a YAML profile file - defp yaml_file?(path) do + @doc false + def yaml_file?(path) do String.ends_with?(path, [".yaml", ".yml"]) end - # Check if the events indicate we should reload - # Reload on created, modified, or removed events - # Don't reload on :undefined or other metadata-only changes - defp should_reload?(events) when is_list(events) do + @doc false + def should_reload?(events) when is_list(events) do Enum.any?(events, fn event -> event in [:created, :modified, :removed, :renamed] end) end - defp should_reload?(_), do: false + def should_reload?(_), do: false end diff --git a/lib/towerops/reports.ex b/lib/towerops/reports.ex index 9b4129f7..425bb8ef 100644 --- a/lib/towerops/reports.ex +++ b/lib/towerops/reports.ex @@ -129,9 +129,17 @@ defmodule Towerops.Reports do {:ok, header <> rows} end - defp csv_escape(nil), do: "" + @doc """ + Escapes a value for inclusion in a CSV cell. - defp csv_escape(value) when is_binary(value) do + Wraps values containing commas, quotes, or newlines in double-quotes and + doubles any embedded quotes. `nil` becomes the empty string; non-binary + non-nil values are coerced via `to_string/1`. + """ + @spec csv_escape(term()) :: String.t() + def csv_escape(nil), do: "" + + def csv_escape(value) when is_binary(value) do if String.contains?(value, [",", "\"", "\n"]) do "\"#{String.replace(value, "\"", "\"\"")}\"" else @@ -139,11 +147,17 @@ defmodule Towerops.Reports do end end - defp csv_escape(value), do: to_string(value) + def csv_escape(value), do: to_string(value) - defp parse_time_range(scope) do + @doc """ + Parses the `days` field of a report's scope map into a `%{from, to}` range + ending at `DateTime.utc_now/0`. Defaults to 7 days when `days` is missing. + """ + @spec parse_time_range(map()) :: %{from: DateTime.t(), to: DateTime.t()} + def parse_time_range(scope) when is_map(scope) do days = Map.get(scope, "days", 7) - %{from: DateTime.add(DateTime.utc_now(), -days, :day), to: DateTime.utc_now()} + now = DateTime.utc_now() + %{from: DateTime.add(now, -days, :day), to: now} end # -- Schedule Helpers -- diff --git a/lib/towerops/snmp/wireless_client_discovery.ex b/lib/towerops/snmp/wireless_client_discovery.ex index ff292ef2..de1798c9 100644 --- a/lib/towerops/snmp/wireless_client_discovery.ex +++ b/lib/towerops/snmp/wireless_client_discovery.ex @@ -9,6 +9,7 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do """ alias Towerops.Snmp.Client + alias Towerops.Snmp.WirelessClientDiscovery.Parser require Logger @@ -48,29 +49,7 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do Determines the vendor profile string from an SNMP device's sys_object_id and manufacturer. """ @spec detect_vendor_profile(map()) :: String.t() | nil - def detect_vendor_profile(snmp_device) do - sys_oid = snmp_device.sys_object_id || "" - manufacturer = String.downcase(snmp_device.manufacturer || "") - - detect_cambium(sys_oid, manufacturer) || - detect_ubiquiti(sys_oid, manufacturer) || - detect_mikrotik(sys_oid, manufacturer) - end - - defp detect_cambium(sys_oid, manufacturer) do - if String.starts_with?(sys_oid, "1.3.6.1.4.1.17713") or String.contains?(manufacturer, "cambium"), - do: "epmp" - end - - defp detect_ubiquiti(sys_oid, manufacturer) do - if String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or String.contains?(manufacturer, "ubiquiti"), - do: "airos" - end - - defp detect_mikrotik(sys_oid, manufacturer) do - if String.starts_with?(sys_oid, "1.3.6.1.4.1.14988") or String.contains?(manufacturer, "mikrotik"), - do: "routeros" - end + defdelegate detect_vendor_profile(snmp_device), to: Parser # --- Cambium ePMP --- @@ -225,96 +204,12 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do |> Enum.reject(fn {idx, _} -> idx == "" end) end - defp get_indexed_value(walk_results, base_oid, idx) do - Map.get(walk_results, "#{base_oid}.#{idx}") - end - - defp get_indexed_int(walk_results, base_oid, idx) do - case get_indexed_value(walk_results, base_oid, idx) do - v when is_integer(v) -> v - _ -> nil - end - end - - defp get_indexed_string(walk_results, base_oid, idx) do - case get_indexed_value(walk_results, base_oid, idx) do - v when is_binary(v) and v != "" -> v - _ -> nil - end - end - - # Format MAC from binary (6 bytes) or string to lowercase colon-separated - defp format_mac(value) when is_binary(value) and byte_size(value) == 6 do - value - |> :binary.bin_to_list() - |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) - |> String.downcase() - end - - defp format_mac(value) when is_binary(value) do - if String.contains?(value, ":") or String.contains?(value, "-") do - # String MAC with separators - normalize to ensure proper padding - value - |> String.downcase() - |> String.replace(~r/[^0-9a-f]/, "") - |> case do - <> -> - "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}" - - # Not 12 hex chars - try to parse as byte string - _ -> - value - |> :binary.bin_to_list() - |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) - |> String.downcase() - end - else - value - |> :binary.bin_to_list() - |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) - |> String.downcase() - end - end - - defp format_mac(_), do: nil - - # Format IP address from SNMP IpAddress type or string - defp format_ip(value) when is_binary(value) and byte_size(value) == 4 do - value - |> :binary.bin_to_list() - |> Enum.join(".") - end - - defp format_ip(value) when is_binary(value) do - if String.match?(value, ~r/^\d+\.\d+\.\d+\.\d+$/) do - value - end - end - - defp format_ip(_), do: nil - - # Pick the best (non-nil) of two values, preferring the first - defp best_value(nil, b), do: if(is_integer(b), do: b) - defp best_value(a, _) when is_integer(a), do: a - defp best_value(_, b) when is_integer(b), do: b - defp best_value(_, _), do: nil - - # Parse ePMP session time (DisplayString like "1d 2h 3m 4s" or seconds) - defp parse_session_time(nil), do: nil - - defp parse_session_time(value) when is_binary(value) do - # Try to parse common formats - case Integer.parse(value) do - {seconds, ""} -> seconds - _ -> nil - end - end - - defp parse_session_time(value) when is_integer(value), do: value - defp parse_session_time(_), do: nil - - # Convert TimeTicks (hundredths of seconds) to seconds - defp timeticks_to_seconds(nil), do: nil - defp timeticks_to_seconds(value) when is_integer(value), do: div(value, 100) - defp timeticks_to_seconds(_), do: nil + defdelegate get_indexed_value(walk_results, base_oid, idx), to: Parser + defdelegate get_indexed_int(walk_results, base_oid, idx), to: Parser + defdelegate get_indexed_string(walk_results, base_oid, idx), to: Parser + defdelegate format_mac(value), to: Parser + defdelegate format_ip(value), to: Parser + defdelegate best_value(a, b), to: Parser + defdelegate parse_session_time(value), to: Parser + defdelegate timeticks_to_seconds(value), to: Parser end diff --git a/lib/towerops/snmp/wireless_client_discovery/parser.ex b/lib/towerops/snmp/wireless_client_discovery/parser.ex new file mode 100644 index 00000000..303c8c59 --- /dev/null +++ b/lib/towerops/snmp/wireless_client_discovery/parser.ex @@ -0,0 +1,174 @@ +defmodule Towerops.Snmp.WirelessClientDiscovery.Parser do + @moduledoc """ + Pure parsing helpers extracted from `Towerops.Snmp.WirelessClientDiscovery`. + + These functions handle SNMP response normalization (MAC/IP formatting, + indexed lookups, session time parsing) without any SNMP or network I/O, + so they can be unit-tested exhaustively. + """ + + @doc """ + Detects the vendor profile for a wireless device based on its SNMP + sys_object_id and manufacturer string. Returns `"epmp"`, `"airos"`, + `"routeros"`, or `nil`. + """ + @spec detect_vendor_profile(map()) :: String.t() | nil + def detect_vendor_profile(%{} = snmp_device) do + sys_oid = Map.get(snmp_device, :sys_object_id) || "" + manufacturer = String.downcase(Map.get(snmp_device, :manufacturer) || "") + + detect_cambium(sys_oid, manufacturer) || + detect_ubiquiti(sys_oid, manufacturer) || + detect_mikrotik(sys_oid, manufacturer) + end + + def detect_vendor_profile(_), do: nil + + defp detect_cambium("1.3.6.1.4.1.17713" <> _, _manufacturer), do: "epmp" + + defp detect_cambium(_oid, manufacturer) do + if String.contains?(manufacturer, "cambium"), do: "epmp" + end + + defp detect_ubiquiti("1.3.6.1.4.1.41112" <> _, _manufacturer), do: "airos" + + defp detect_ubiquiti(_oid, manufacturer) do + if String.contains?(manufacturer, "ubiquiti"), do: "airos" + end + + defp detect_mikrotik("1.3.6.1.4.1.14988" <> _, _manufacturer), do: "routeros" + + defp detect_mikrotik(_oid, manufacturer) do + if String.contains?(manufacturer, "mikrotik"), do: "routeros" + end + + @doc """ + Extracts indexed value from a walk result map keyed by `#{"{base_oid}.{idx}"}`. + """ + @spec get_indexed_value(map(), String.t(), String.t()) :: term() + def get_indexed_value(walk_results, base_oid, idx) when is_map(walk_results) do + Map.get(walk_results, "#{base_oid}.#{idx}") + end + + def get_indexed_value(_, _, _), do: nil + + @doc """ + Like `get_indexed_value/3` but returns `nil` unless the value is an integer. + """ + @spec get_indexed_int(map(), String.t(), String.t()) :: integer() | nil + def get_indexed_int(walk_results, base_oid, idx) do + case get_indexed_value(walk_results, base_oid, idx) do + v when is_integer(v) -> v + _ -> nil + end + end + + @doc """ + Like `get_indexed_value/3` but returns `nil` unless the value is a non-empty string. + """ + @spec get_indexed_string(map(), String.t(), String.t()) :: String.t() | nil + def get_indexed_string(walk_results, base_oid, idx) do + case get_indexed_value(walk_results, base_oid, idx) do + v when is_binary(v) and v != "" -> v + _ -> nil + end + end + + @doc """ + Formats a MAC address to lowercase colon-separated form. + + Accepts: + - 6-byte binary (SNMP OctetString) + - Separated strings like `"AA:BB:CC:DD:EE:01"` or `"aa-bb-cc-dd-ee-01"` + - Other binary forms are treated as raw bytes + """ + @spec format_mac(term()) :: String.t() | nil + def format_mac(value) when is_binary(value) and byte_size(value) == 6 do + value + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + + def format_mac(value) when is_binary(value) do + if has_separator?(value) do + normalize_separated_mac(value) + else + bytes_to_mac(value) + end + end + + def format_mac(_), do: nil + + defp has_separator?(value) do + String.contains?(value, ":") or String.contains?(value, "-") + end + + defp normalize_separated_mac(value) do + cleaned = value |> String.downcase() |> String.replace(~r/[^0-9a-f]/, "") + + case cleaned do + <> -> + "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}" + + _ -> + bytes_to_mac(value) + end + end + + defp bytes_to_mac(value) do + value + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + + @doc """ + Formats an IP address from a 4-byte binary (SNMP IpAddress) or pre-formatted + dotted string. Returns `nil` for invalid input. + """ + @spec format_ip(term()) :: String.t() | nil + def format_ip(value) when is_binary(value) and byte_size(value) == 4 do + value |> :binary.bin_to_list() |> Enum.join(".") + end + + def format_ip(value) when is_binary(value) do + if String.match?(value, ~r/\A\d+\.\d+\.\d+\.\d+\z/), do: value + end + + def format_ip(_), do: nil + + @doc """ + Returns the "better" of two values — prefers the first if it's an integer, + otherwise uses the second if integer, otherwise `nil`. + """ + @spec best_value(term(), term()) :: integer() | nil + def best_value(a, _) when is_integer(a), do: a + def best_value(_, b) when is_integer(b), do: b + def best_value(_, _), do: nil + + @doc """ + Parses a session-time value. Accepts integer (returned as-is), integer + string (parsed), or anything else → `nil`. + """ + @spec parse_session_time(term()) :: integer() | nil + def parse_session_time(nil), do: nil + def parse_session_time(v) when is_integer(v), do: v + + def parse_session_time(v) when is_binary(v) do + case Integer.parse(v) do + {seconds, ""} -> seconds + _ -> nil + end + end + + def parse_session_time(_), do: nil + + @doc """ + Converts SNMP TimeTicks (hundredths of a second) to seconds. + """ + @spec timeticks_to_seconds(term()) :: integer() | nil + def timeticks_to_seconds(nil), do: nil + def timeticks_to_seconds(v) when is_integer(v), do: div(v, 100) + def timeticks_to_seconds(_), do: nil +end diff --git a/lib/towerops/sonar/client.ex b/lib/towerops/sonar/client.ex index 8e36058f..08e2b39b 100644 --- a/lib/towerops/sonar/client.ex +++ b/lib/towerops/sonar/client.ex @@ -151,19 +151,18 @@ defmodule Towerops.Sonar.Client do json: %{"query" => query_string, "variables" => variables} ] - req_opts = - if Application.get_env(:towerops, :env) == :test do - Keyword.put(req_opts, :plug, {Req.Test, __MODULE__}) - else - req_opts - end - - Req.request(req_opts) + req_opts + |> maybe_attach_test_plug(Application.get_env(:towerops, :env)) + |> Req.request() rescue exception -> {:error, Exception.message(exception)} end + defp maybe_attach_test_plug(req_opts, :test), do: Keyword.put(req_opts, :plug, {Req.Test, __MODULE__}) + + defp maybe_attach_test_plug(req_opts, _env), do: req_opts + defp get_retry_after(headers) when is_map(headers) do case Map.get(headers, "retry-after") do [value | _] -> parse_retry_after(value) diff --git a/lib/towerops/sonar/sync.ex b/lib/towerops/sonar/sync.ex index c28855d0..a9077f1d 100644 --- a/lib/towerops/sonar/sync.ex +++ b/lib/towerops/sonar/sync.ex @@ -50,32 +50,39 @@ defmodule Towerops.Sonar.Sync do end end - defp calculate_total_mrr(accounts) do + @doc false + def calculate_total_mrr(accounts) do Enum.reduce(accounts, 0.0, fn account, total -> services = account["account_services"] || [] total + sum_service_prices(services) end) end - defp sum_service_prices(services) do - Enum.reduce(services, 0.0, fn service, acc -> - price = service["price_override"] - if is_number(price), do: acc + price, else: acc - end) + @doc false + def sum_service_prices(services) do + Enum.reduce(services, 0.0, fn service, acc -> add_price(acc, service["price_override"]) end) end - defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token" - defp humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions" - defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s" - defp humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}" + @doc false + def add_price(acc, price) when is_number(price), do: acc + price + def add_price(acc, _price), do: acc - defp humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do + @doc false + def humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token" + + def humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions" + + def humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s" + + def humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}" + + def humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1))) "GraphQL errors: #{messages}" end - defp humanize_sync_error(reason) when is_binary(reason) do - if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + def humanize_sync_error(reason) when is_binary(reason) do + if ssl_failure?(reason) do "SSL certificate verification failed — unable to establish a secure connection" else Logger.warning("Sync failed with raw error: #{reason}") @@ -83,8 +90,10 @@ defmodule Towerops.Sonar.Sync do end end - defp humanize_sync_error(reason) do + def humanize_sync_error(reason) do Logger.warning("Sonar sync failed with unexpected error: #{inspect(reason)}") "An unexpected error occurred during sync" end + + defp ssl_failure?(reason), do: String.contains?(reason, ["CA trust store", "cacert", "certificate"]) end diff --git a/lib/towerops/topology/lldp.ex b/lib/towerops/topology/lldp.ex index 1f93a930..91d48593 100644 --- a/lib/towerops/topology/lldp.ex +++ b/lib/towerops/topology/lldp.ex @@ -36,6 +36,7 @@ defmodule Towerops.Topology.Lldp do alias Towerops.Devices alias Towerops.Snmp.Client + alias Towerops.Topology.Lldp.Parser require Logger @@ -210,69 +211,10 @@ defmodule Towerops.Topology.Lldp do |> Enum.reject(fn n -> n.neighbor_name == "" end) end - # Extracts the suffix after the base OID - defp extract_suffix(oid, base) do - prefix = base <> "." + defdelegate extract_suffix(oid, base), to: Parser + defdelegate parse_remote_key(oid, base), to: Parser - if String.starts_with?(oid, prefix) do - String.replace_prefix(oid, prefix, "") - end - end - - # Parses remote table key from OID: timeMark.portNum.remIndex - defp parse_remote_key(oid, base) do - case extract_suffix(oid, base) do - nil -> - nil - - suffix -> - case String.split(suffix, ".", parts: 4) do - [time_mark, port_num, rem_index | _] -> - {time_mark, port_num, rem_index} - - _ -> - nil - end - end - end - - # Parses management address from OID index - # Format: timeMark.portNum.remIndex.addrSubtype.addrLen.addr[bytes] - # addrSubtype: 1=IPv4, 2=IPv6 - defp parse_management_address(oid) do - with suffix when not is_nil(suffix) <- extract_suffix(oid, @oid_rem_man_addr), - parts = String.split(suffix, "."), - true <- length(parts) >= 9, - [time_mark, port_num, rem_index, addr_subtype | rest] <- parts, - ip when not is_nil(ip) <- parse_ip_from_addr_subtype(addr_subtype, rest) do - {{time_mark, port_num, rem_index}, ip} - else - _ -> nil - end - end - - # Parses IP address based on address subtype - defp parse_ip_from_addr_subtype("1", rest), do: parse_ipv4(Enum.slice(rest, 1, 4)) - defp parse_ip_from_addr_subtype("2", rest), do: parse_ipv6(Enum.slice(rest, 1, 16)) - defp parse_ip_from_addr_subtype(_, _), do: nil - - # Parse IPv4 address from OID octets - defp parse_ipv4(octets) when length(octets) == 4 do - Enum.join(octets, ".") - end - - defp parse_ipv4(_), do: nil - - # Parse IPv6 address from OID octets - defp parse_ipv6(octets) when length(octets) == 16 do - octets - |> Enum.map(&String.to_integer/1) - |> Enum.chunk_every(2) - |> Enum.map_join(":", fn [a, b] -> Integer.to_string(a * 256 + b, 16) end) - |> String.downcase() - end - - defp parse_ipv6(_), do: nil + defp parse_management_address(oid), do: Parser.parse_management_address(oid, @oid_rem_man_addr) # Builds SNMP client options from device configuration defp build_client_opts(device) do @@ -284,30 +226,23 @@ defmodule Towerops.Topology.Lldp do port: device.snmp_port || 161 ] - # Add version-specific credentials - if snmp_config.version == "3" do - v3_config = Devices.get_snmpv3_config(device) - - base_opts ++ - [ - security_name: v3_config.username, - security_level: v3_config.security_level, - auth_protocol: v3_config.auth_protocol, - auth_password: v3_config.auth_password, - priv_protocol: v3_config.priv_protocol, - priv_password: v3_config.priv_password - ] - else - base_opts ++ [community: snmp_config.community] - end + base_opts ++ version_credentials(snmp_config, device) end - # Cleans string values from SNMP (removes control characters, trims whitespace) - defp clean_string(value) when is_binary(value) do - value - |> String.trim() - |> String.replace(~r/[\x00-\x1F\x7F]/, "") + defp version_credentials(%{version: "3"}, device) do + v3 = Devices.get_snmpv3_config(device) + + [ + security_name: v3.username, + security_level: v3.security_level, + auth_protocol: v3.auth_protocol, + auth_password: v3.auth_password, + priv_protocol: v3.priv_protocol, + priv_password: v3.priv_password + ] end - defp clean_string(value), do: to_string(value) + defp version_credentials(snmp_config, _device), do: [community: snmp_config.community] + + defdelegate clean_string(value), to: Parser end diff --git a/lib/towerops/topology/lldp/parser.ex b/lib/towerops/topology/lldp/parser.ex new file mode 100644 index 00000000..cf5a13a4 --- /dev/null +++ b/lib/towerops/topology/lldp/parser.ex @@ -0,0 +1,105 @@ +defmodule Towerops.Topology.Lldp.Parser do + @moduledoc """ + Pure parsers for LLDP-MIB OID suffixes and management addresses. + + Extracted from `Towerops.Topology.Lldp` so parsing logic can be tested + without SNMP or database dependencies. + """ + + @type remote_key :: {time_mark :: String.t(), port_num :: String.t(), rem_index :: String.t()} + + @doc """ + Extracts the portion of `oid` after `base <> "."`, or `nil` if the OID + does not start with the expected prefix. + """ + @spec extract_suffix(String.t(), String.t()) :: String.t() | nil + def extract_suffix(oid, base) do + prefix = base <> "." + + if String.starts_with?(oid, prefix) do + String.replace_prefix(oid, prefix, "") + end + end + + @doc """ + Parses a remote-table OID key (timeMark.portNum.remIndex.*). + + Returns the 3-tuple or `nil` when the suffix cannot be decomposed. + """ + @spec parse_remote_key(String.t(), String.t()) :: remote_key() | nil + def parse_remote_key(oid, base) do + with suffix when not is_nil(suffix) <- extract_suffix(oid, base), + [time_mark, port_num, rem_index | _] <- String.split(suffix, ".", parts: 4) do + {time_mark, port_num, rem_index} + else + _ -> nil + end + end + + @doc """ + Parses a management address OID of the form: + `base.timeMark.portNum.remIndex.addrSubtype.addrLen.addr[bytes]`. + + Returns `{remote_key, ip_string}` or `nil` on any parse failure. + `addrSubtype` is `"1"` for IPv4 and `"2"` for IPv6. + """ + @spec parse_management_address(String.t(), String.t()) :: + {remote_key(), String.t()} | nil + def parse_management_address(oid, base) do + with suffix when not is_nil(suffix) <- extract_suffix(oid, base), + parts = String.split(suffix, "."), + true <- length(parts) >= 9, + [time_mark, port_num, rem_index, addr_subtype | rest] <- parts, + ip when not is_nil(ip) <- parse_ip_from_addr_subtype(addr_subtype, rest) do + {{time_mark, port_num, rem_index}, ip} + else + _ -> nil + end + end + + @doc """ + Builds an IP string from an OID-encoded address given its subtype. + + Only `"1"` (IPv4) and `"2"` (IPv6) are recognized; all other subtypes + return `nil`. Returns `nil` if the byte list is malformed. + """ + @spec parse_ip_from_addr_subtype(String.t(), [String.t()]) :: String.t() | nil + def parse_ip_from_addr_subtype("1", rest), do: parse_ipv4(Enum.slice(rest, 1, 4)) + def parse_ip_from_addr_subtype("2", rest), do: parse_ipv6(Enum.slice(rest, 1, 16)) + def parse_ip_from_addr_subtype(_, _), do: nil + + @doc """ + Joins 4 string octets into a dotted IPv4 string, or returns `nil`. + """ + @spec parse_ipv4([String.t()]) :: String.t() | nil + def parse_ipv4(octets) when length(octets) == 4, do: Enum.join(octets, ".") + def parse_ipv4(_), do: nil + + @doc """ + Converts 16 decimal-string octets into a lowercase colon-separated IPv6 + string, or returns `nil` if input shape is wrong. + """ + @spec parse_ipv6([String.t()]) :: String.t() | nil + def parse_ipv6(octets) when length(octets) == 16 do + octets + |> Enum.map(&String.to_integer/1) + |> Enum.chunk_every(2) + |> Enum.map_join(":", fn [a, b] -> Integer.to_string(a * 256 + b, 16) end) + |> String.downcase() + end + + def parse_ipv6(_), do: nil + + @doc """ + Strips control characters and surrounding whitespace from an SNMP string. + Non-binary values are coerced via `to_string/1`. + """ + @spec clean_string(term()) :: String.t() + def clean_string(value) when is_binary(value) do + value + |> String.trim() + |> String.replace(~r/[\x00-\x1F\x7F]/, "") + end + + def clean_string(value), do: to_string(value) +end diff --git a/lib/towerops/trace.ex b/lib/towerops/trace.ex index dd413b18..3c990b80 100644 --- a/lib/towerops/trace.ex +++ b/lib/towerops/trace.ex @@ -433,23 +433,34 @@ defmodule Towerops.Trace do |> Repo.all() end - defp format_address(nil), do: nil + @doc """ + Formats a structured address map into a single comma-separated string. - defp format_address(addr) when is_map(addr) do - parts = - Enum.reject( - [ - addr["street1"] || addr["line1"], - addr["street2"] || addr["line2"], - addr["city"], - addr["state"] || addr["province"], - addr["zip"] || addr["postal_code"] - ], - &(is_nil(&1) or String.trim(&1) == "") - ) + Accepts either `line1/line2` or `street1/street2` styling and either + `state/province` and `zip/postal_code`. Returns `nil` for empty maps + or non-map input. + """ + @spec format_address(map() | nil | term()) :: String.t() | nil + def format_address(nil), do: nil - if parts == [], do: nil, else: Enum.join(parts, ", ") + def format_address(addr) when is_map(addr) do + [ + addr["street1"] || addr["line1"], + addr["street2"] || addr["line2"], + addr["city"], + addr["state"] || addr["province"], + addr["zip"] || addr["postal_code"] + ] + |> Enum.reject(&blank?/1) + |> join_or_nil() end - defp format_address(_), do: nil + def format_address(_), do: nil + + defp blank?(nil), do: true + defp blank?(s) when is_binary(s), do: String.trim(s) == "" + defp blank?(_), do: false + + defp join_or_nil([]), do: nil + defp join_or_nil(parts), do: Enum.join(parts, ", ") end diff --git a/lib/towerops/uisp/config_snapshot.ex b/lib/towerops/uisp/config_snapshot.ex index 092f9890..b817ab5e 100644 --- a/lib/towerops/uisp/config_snapshot.ex +++ b/lib/towerops/uisp/config_snapshot.ex @@ -41,7 +41,8 @@ defmodule Towerops.Uisp.ConfigSnapshot do Client.request_raw(:get, "#{base_url}/devices/#{device_id}", api_key) end - defp store_if_changed(device, config_data, acc) do + @doc false + def store_if_changed(device, config_data, acc) do config_json = Jason.encode!(config_data) config_hash = :sha256 |> :crypto.hash(config_json) |> Base.encode16(case: :lower) @@ -73,9 +74,11 @@ defmodule Towerops.Uisp.ConfigSnapshot do Gets the latest config snapshot for a device. """ def get_latest_snapshot(device_id) do + device_id = cast_uuid(device_id) + Repo.one( from(s in "uisp_config_snapshots", - where: s.device_id == ^device_id, + where: s.device_id == type(^device_id, :binary_id), order_by: [desc: s.snapshot_at], limit: 1, select: %{ @@ -93,9 +96,11 @@ defmodule Towerops.Uisp.ConfigSnapshot do Lists config snapshots for a device, most recent first. """ def list_snapshots(device_id, limit \\ 20) do + device_id = cast_uuid(device_id) + Repo.all( from(s in "uisp_config_snapshots", - where: s.device_id == ^device_id, + where: s.device_id == type(^device_id, :binary_id), order_by: [desc: s.snapshot_at], limit: ^limit, select: %{ @@ -113,9 +118,11 @@ defmodule Towerops.Uisp.ConfigSnapshot do Gets and decompresses a specific snapshot's config. """ def get_config(snapshot_id) do + snapshot_id = cast_uuid(snapshot_id) + case Repo.one( from(s in "uisp_config_snapshots", - where: s.id == ^snapshot_id, + where: s.id == type(^snapshot_id, :binary_id), select: s.config_compressed ) ) do @@ -127,6 +134,18 @@ defmodule Towerops.Uisp.ConfigSnapshot do end end + # Normalizes a UUID input — schemas hand us a string; callers may pass a + # 16-byte binary. `type(^value, :binary_id)` handles both uniformly as long + # as the Elixir value is a string. + defp cast_uuid(value) when is_binary(value) and byte_size(value) == 16 do + case Ecto.UUID.load(value) do + {:ok, str} -> str + _ -> value + end + end + + defp cast_uuid(value), do: value + @doc """ Diffs two config snapshots, returning added/removed/changed keys. """ @@ -137,36 +156,43 @@ defmodule Towerops.Uisp.ConfigSnapshot do end end - defp compute_diff(a, b) when is_map(a) and is_map(b) do + @doc """ + Computes a shallow diff of two config maps. + + Returns `%{added: [...], removed: [...], changed: [...]}` where: + - `added` entries are `{key, new_value}` (present only in `b`) + - `removed` entries are `{key, old_value}` (present only in `a`) + - `changed` entries are `{key, old_value, new_value}` (differ between sides) + + Non-map inputs yield an empty diff. + """ + @spec compute_diff(map(), map()) :: %{added: list(), removed: list(), changed: list()} + def compute_diff(a, b) when is_map(a) and is_map(b) do all_keys = MapSet.union(MapSet.new(Map.keys(a)), MapSet.new(Map.keys(b))) Enum.reduce(all_keys, %{added: [], removed: [], changed: []}, fn key, acc -> - case {Map.get(a, key), Map.get(b, key)} do - {nil, new_val} -> - Map.update!(acc, :added, &[{key, new_val} | &1]) - - {old_val, nil} -> - Map.update!(acc, :removed, &[{key, old_val} | &1]) - - {same, same} -> - acc - - {old_val, new_val} -> - Map.update!(acc, :changed, &[{key, old_val, new_val} | &1]) - end + classify_key(acc, key, Map.get(a, key), Map.get(b, key)) end) end - defp compute_diff(_a, _b), do: %{added: [], removed: [], changed: []} + def compute_diff(_a, _b), do: %{added: [], removed: [], changed: []} + + defp classify_key(acc, key, nil, new_val), do: Map.update!(acc, :added, &[{key, new_val} | &1]) + + defp classify_key(acc, key, old_val, nil), do: Map.update!(acc, :removed, &[{key, old_val} | &1]) + + defp classify_key(acc, _key, same, same), do: acc + + defp classify_key(acc, key, old_val, new_val), do: Map.update!(acc, :changed, &[{key, old_val, new_val} | &1]) defp insert_snapshot(attrs) do + {:ok, id_binary} = Ecto.UUID.dump(Ecto.UUID.generate()) + {:ok, device_id_binary} = Ecto.UUID.dump(attrs.device_id) + + row = Map.merge(attrs, %{id: id_binary, device_id: device_id_binary, inserted_at: Towerops.Time.now()}) + "uisp_config_snapshots" - |> Repo.insert_all([ - Map.merge(attrs, %{ - id: Ecto.UUID.generate(), - inserted_at: Towerops.Time.now() - }) - ]) + |> Repo.insert_all([row]) |> case do {1, _} -> {:ok, :inserted} _ -> {:error, :insert_failed} diff --git a/lib/towerops/uisp/gps_sync.ex b/lib/towerops/uisp/gps_sync.ex index 65ac8483..90beb431 100644 --- a/lib/towerops/uisp/gps_sync.ex +++ b/lib/towerops/uisp/gps_sync.ex @@ -69,22 +69,24 @@ defmodule Towerops.Uisp.GpsSync do end end - defp valid_coords?(lat, lon) do - lat = to_float(lat) - lon = to_float(lon) - is_number(lat) and is_number(lon) and lat != 0.0 and lon != 0.0 - end + @doc false + def valid_coords?(lat, lon), do: check_coords(to_float(lat), to_float(lon)) - defp to_float(nil), do: nil - defp to_float(v) when is_float(v), do: v - defp to_float(v) when is_integer(v), do: v / 1 + defp check_coords(lat, lon) when is_number(lat) and is_number(lon) and lat != 0.0 and lon != 0.0, do: true - defp to_float(v) when is_binary(v) do + defp check_coords(_lat, _lon), do: false + + @doc false + def to_float(nil), do: nil + def to_float(v) when is_float(v), do: v + def to_float(v) when is_integer(v), do: v / 1 + + def to_float(v) when is_binary(v) do case Float.parse(v) do {f, _} -> f :error -> nil end end - defp to_float(_), do: nil + def to_float(_), do: nil end diff --git a/lib/towerops/uisp/statistics_sync.ex b/lib/towerops/uisp/statistics_sync.ex index 43482766..d22841cd 100644 --- a/lib/towerops/uisp/statistics_sync.ex +++ b/lib/towerops/uisp/statistics_sync.ex @@ -59,20 +59,23 @@ defmodule Towerops.Uisp.StatisticsSync do defp process_single_stat(local_device, stat, acc) do interface_name = stat["interfaceName"] || stat["name"] || "unknown" - checked_at = parse_timestamp(stat["timestamp"]) + dispatch_stat(local_device, stat, interface_name, parse_timestamp(stat["timestamp"]), acc) + end - cond do - is_nil(checked_at) -> - Map.update!(acc, :skipped_dedup, &(&1 + 1)) + defp dispatch_stat(_dev, _stat, _iface, nil, acc), do: Map.update!(acc, :skipped_dedup, &(&1 + 1)) - recently_polled?(local_device.id, interface_name, checked_at) -> - Map.update!(acc, :skipped_dedup, &(&1 + 1)) + defp dispatch_stat(dev, stat, iface, checked_at, acc) do + if recently_polled?(dev.id, iface, checked_at) do + Map.update!(acc, :skipped_dedup, &(&1 + 1)) + else + insert_and_record(dev, iface, stat, checked_at, acc) + end + end - true -> - case insert_stat(local_device, interface_name, stat, checked_at) do - {:ok, _} -> Map.update!(acc, :inserted, &(&1 + 1)) - {:error, _} -> acc - end + defp insert_and_record(dev, iface, stat, checked_at, acc) do + case insert_stat(dev, iface, stat, checked_at) do + {:ok, _} -> Map.update!(acc, :inserted, &(&1 + 1)) + {:error, _} -> acc end end @@ -123,31 +126,36 @@ defmodule Towerops.Uisp.StatisticsSync do |> Repo.one() end - defp parse_counter(nil), do: 0 - defp parse_counter(val) when is_integer(val), do: val - defp parse_counter(val) when is_float(val), do: round(val) + @doc false + def parse_counter(nil), do: 0 + def parse_counter(val) when is_integer(val), do: val + def parse_counter(val) when is_float(val), do: round(val) - defp parse_counter(val) when is_binary(val) do + def parse_counter(val) when is_binary(val) do case Integer.parse(val) do {n, _} -> n :error -> 0 end end - defp parse_counter(_), do: 0 + def parse_counter(_), do: 0 - defp parse_timestamp(nil), do: nil + @doc false + def parse_timestamp(nil), do: nil - defp parse_timestamp(ts) when is_binary(ts) do + def parse_timestamp(ts) when is_binary(ts) do case DateTime.from_iso8601(ts) do {:ok, dt, _offset} -> DateTime.truncate(dt, :second) _ -> nil end end - defp parse_timestamp(ts) when is_integer(ts) do - ts |> DateTime.from_unix() |> elem(1) |> DateTime.truncate(:second) + def parse_timestamp(ts) when is_integer(ts) do + case DateTime.from_unix(ts) do + {:ok, dt} -> DateTime.truncate(dt, :second) + {:error, _} -> nil + end end - defp parse_timestamp(_), do: nil + def parse_timestamp(_), do: nil end diff --git a/lib/towerops/weather.ex b/lib/towerops/weather.ex index 25e0f133..b40647ce 100644 --- a/lib/towerops/weather.ex +++ b/lib/towerops/weather.ex @@ -43,18 +43,18 @@ defmodule Towerops.Weather do Returns a map of site_id => observation. """ def latest_observations_for_org(organization_id) do - # Subquery to get latest observation per site - latest_ids = + # Subquery: latest observed_at per site (IDs are UUIDs, so max(id) is invalid). + latest_timestamps = from(o in Observation, join: s in assoc(o, :site), where: s.organization_id == ^organization_id, group_by: o.site_id, - select: %{site_id: o.site_id, max_id: max(o.id)} + select: %{site_id: o.site_id, max_observed_at: max(o.observed_at)} ) from(o in Observation, - join: l in subquery(latest_ids), - on: o.id == l.max_id + join: l in subquery(latest_timestamps), + on: o.site_id == l.site_id and o.observed_at == l.max_observed_at ) |> Repo.all() |> Map.new(fn obs -> {obs.site_id, obs} end) @@ -135,22 +135,21 @@ defmodule Towerops.Weather do Fetches and stores current weather for a site. Requires the site to have latitude and longitude set. """ - def fetch_and_store(site) do - if site.latitude && site.longitude do - case Client.get_current_weather(site.latitude, site.longitude) do - {:ok, data} -> - attrs = Observation.from_owm(site.id, data) - create_observation(attrs) + def fetch_and_store(%{latitude: lat, longitude: lon} = site) when not is_nil(lat) and not is_nil(lon) do + case Client.get_current_weather(lat, lon) do + {:ok, data} -> + site.id + |> Observation.from_owm(data) + |> create_observation() - {:error, reason} -> - Logger.warning("Failed to fetch weather for site #{site.name}: #{inspect(reason)}") - {:error, reason} - end - else - {:error, :no_coordinates} + {:error, reason} -> + Logger.warning("Failed to fetch weather for site #{site.name}: #{inspect(reason)}") + {:error, reason} end end + def fetch_and_store(_site), do: {:error, :no_coordinates} + # ── Correlation Helpers ────────────────────────────────────────────── @doc """ @@ -210,16 +209,16 @@ defmodule Towerops.Weather do :clear, :mild, :moderate, :severe """ def severity(%Observation{} = obs) do - cond do - severe_severity?(obs) -> :severe - moderate_severity?(obs) -> :moderate - mild_severity?(obs) -> :mild - true -> :clear - end + classify_severity({severe_severity?(obs), moderate_severity?(obs), mild_severity?(obs)}) end def severity(_), do: :clear + defp classify_severity({true, _, _}), do: :severe + defp classify_severity({_, true, _}), do: :moderate + defp classify_severity({_, _, true}), do: :mild + defp classify_severity(_), do: :clear + defp high_wind?(obs), do: (obs.wind_speed_ms || 0) > 15 defp strong_gust?(obs), do: (obs.wind_gust_ms || 0) > 20 defp heavy_rain?(obs), do: (obs.rain_1h_mm || 0) > 10 diff --git a/lib/towerops/weather/client.ex b/lib/towerops/weather/client.ex index a4939c50..c24febbc 100644 --- a/lib/towerops/weather/client.ex +++ b/lib/towerops/weather/client.ex @@ -18,36 +18,41 @@ defmodule Towerops.Weather.Client do Returns `{:ok, map}` with the raw OWM response or `{:error, reason}`. """ def get_current_weather(lat, lon, opts \\ []) do - api_key = opts[:api_key] || api_key() + with {:ok, key} <- resolve_api_key(opts[:api_key]) do + req_opts = + maybe_add_test_plug( + [url: "#{@base_url}/weather", params: [lat: lat, lon: lon, appid: key]], + opts + ) - if is_nil(api_key) or api_key == "" do - {:error, :no_api_key} - else - url = "#{@base_url}/weather" - - req_opts = maybe_add_test_plug([url: url, params: [lat: lat, lon: lon, appid: api_key]], opts) - - case HTTP.get(__MODULE__, req_opts) do - {:ok, %Req.Response{status: 200, body: body}} -> - {:ok, body} - - {:ok, %Req.Response{status: 401}} -> - {:error, :invalid_api_key} - - {:ok, %Req.Response{status: 429}} -> - {:error, :rate_limited} - - {:ok, %Req.Response{status: status, body: body}} -> - Logger.warning("OWM API error", status: status, body: inspect(body)) - {:error, {:api_error, status}} - - {:error, reason} -> - Logger.warning("OWM API request failed", error: inspect(reason)) - {:error, reason} - end + __MODULE__ + |> HTTP.get(req_opts) + |> handle_response() end end + defp resolve_api_key(nil), do: validate_api_key(api_key()) + defp resolve_api_key(""), do: {:error, :no_api_key} + defp resolve_api_key(key) when is_binary(key), do: {:ok, key} + + defp validate_api_key(nil), do: {:error, :no_api_key} + defp validate_api_key(""), do: {:error, :no_api_key} + defp validate_api_key(key) when is_binary(key), do: {:ok, key} + + defp handle_response({:ok, %Req.Response{status: 200, body: body}}), do: {:ok, body} + defp handle_response({:ok, %Req.Response{status: 401}}), do: {:error, :invalid_api_key} + defp handle_response({:ok, %Req.Response{status: 429}}), do: {:error, :rate_limited} + + defp handle_response({:ok, %Req.Response{status: status, body: body}}) do + Logger.warning("OWM API error", status: status, body: inspect(body)) + {:error, {:api_error, status}} + end + + defp handle_response({:error, reason}) do + Logger.warning("OWM API request failed", error: inspect(reason)) + {:error, reason} + end + @doc """ Tests if the API key is valid by making a minimal request. """ @@ -61,10 +66,9 @@ defmodule Towerops.Weather.Client do end defp maybe_add_test_plug(req_opts, opts) do - if opts[:plug] do - Keyword.put(req_opts, :plug, opts[:plug]) - else - req_opts + case Keyword.fetch(opts, :plug) do + {:ok, plug} -> Keyword.put(req_opts, :plug, plug) + :error -> req_opts end end end diff --git a/lib/towerops/workers/check_worker.ex b/lib/towerops/workers/check_worker.ex index 2ab46b0d..08059c3d 100644 --- a/lib/towerops/workers/check_worker.ex +++ b/lib/towerops/workers/check_worker.ex @@ -183,15 +183,22 @@ defmodule Towerops.Workers.CheckWorker do :ok end - defp state_changed_to_problem?(old_state, check) do - # State changed from OK to CRITICAL/WARNING and is now hard state - old_state == 0 && check.current_state in [1, 2] && check.current_state_type == "hard" - end + @doc """ + True when a check transitioned from OK (0) to a hard non-OK state (1 or 2). - defp state_changed_to_ok?(old_state, check) do - # State changed from CRITICAL/WARNING to OK - old_state in [1, 2] && check.current_state == 0 - end + Requires `current_state_type == "hard"` to avoid alerting on soft-state flaps. + """ + @spec state_changed_to_problem?(integer(), map()) :: boolean() + def state_changed_to_problem?(0, %{current_state: s, current_state_type: "hard"}) when s in [1, 2], do: true + + def state_changed_to_problem?(_old, _check), do: false + + @doc """ + True when a check transitioned from a non-OK state (1 or 2) back to OK (0). + """ + @spec state_changed_to_ok?(integer(), map()) :: boolean() + def state_changed_to_ok?(old, %{current_state: 0}) when old in [1, 2], do: true + def state_changed_to_ok?(_old, _check), do: false defp handle_check_problem(check, output) do Logger.warning("Check #{check.name} entered problem state", diff --git a/lib/towerops/workers/device_poller_worker.ex b/lib/towerops/workers/device_poller_worker.ex index 3fb8023b..b4f14286 100644 --- a/lib/towerops/workers/device_poller_worker.ex +++ b/lib/towerops/workers/device_poller_worker.ex @@ -1416,11 +1416,12 @@ defmodule Towerops.Workers.DevicePollerWorker do defp determine_speed_change_severity(false, old_speed, new_speed) when new_speed < old_speed, do: "warning" defp determine_speed_change_severity(false, _old_speed, _new_speed), do: "info" - defp format_speed_change_message(if_name, true, _old_speed, new_speed) do + @doc false + def format_speed_change_message(if_name, true, _old_speed, new_speed) do "Interface #{if_name} speed detected: #{format_speed(new_speed)}" end - defp format_speed_change_message(if_name, false, old_speed, new_speed) do + def format_speed_change_message(if_name, false, old_speed, new_speed) do "Interface #{if_name} speed changed from #{format_speed(old_speed)} to #{format_speed(new_speed)}" end @@ -1454,11 +1455,12 @@ defmodule Towerops.Workers.DevicePollerWorker do } end - defp format_mac_change_message(if_name, true, _old_mac, new_mac) do + @doc false + def format_mac_change_message(if_name, true, _old_mac, new_mac) do "Interface #{if_name} MAC address detected: #{new_mac}" end - defp format_mac_change_message(if_name, false, old_mac, new_mac) do + def format_mac_change_message(if_name, false, old_mac, new_mac) do "Interface #{if_name} MAC address changed from #{old_mac} to #{new_mac}" end @@ -1494,43 +1496,41 @@ defmodule Towerops.Workers.DevicePollerWorker do end) end - defp parse_interface_integer(value) when is_integer(value), do: value - defp parse_interface_integer(_), do: nil + @doc false + def parse_interface_integer(value) when is_integer(value), do: value + def parse_interface_integer(_), do: nil - defp parse_if_status(1), do: "up" - defp parse_if_status(2), do: "down" - defp parse_if_status(3), do: "testing" - defp parse_if_status(_), do: "unknown" + @doc false + def parse_if_status(1), do: "up" + def parse_if_status(2), do: "down" + def parse_if_status(3), do: "testing" + def parse_if_status(_), do: "unknown" - defp format_mac_address(<<>>) when is_binary(<<>>), do: nil - defp format_mac_address(nil), do: nil + @doc false + def format_mac_address(<<>>) when is_binary(<<>>), do: nil + def format_mac_address(nil), do: nil - defp format_mac_address(mac) when is_binary(mac) do + def format_mac_address(mac) when is_binary(mac) do mac |> :binary.bin_to_list() |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) |> String.downcase() end - defp format_mac_address(_), do: nil + def format_mac_address(_), do: nil - defp format_speed(speed_bps) when is_integer(speed_bps) do - cond do - speed_bps >= 1_000_000_000 -> - "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" + @doc false + def format_speed(speed_bps) when is_integer(speed_bps) and speed_bps >= 1_000_000_000, + do: "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" - speed_bps >= 1_000_000 -> - "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" + def format_speed(speed_bps) when is_integer(speed_bps) and speed_bps >= 1_000_000, + do: "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" - speed_bps >= 1_000 -> - "#{Float.round(speed_bps / 1_000, 1)} Kbps" + def format_speed(speed_bps) when is_integer(speed_bps) and speed_bps >= 1_000, + do: "#{Float.round(speed_bps / 1_000, 1)} Kbps" - true -> - "#{speed_bps} bps" - end - end - - defp format_speed(_), do: "Unknown" + def format_speed(speed_bps) when is_integer(speed_bps), do: "#{speed_bps} bps" + def format_speed(_), do: "Unknown" defp get_interface_stats(client_opts, if_index) do hc_oids = [ @@ -1604,11 +1604,12 @@ defmodule Towerops.Workers.DevicePollerWorker do end end - defp decode_snmp_value(value) when is_number(value) do + @doc false + def decode_snmp_value(value) when is_number(value) do value end - defp decode_snmp_value(value) when is_binary(value) do + def decode_snmp_value(value) when is_binary(value) do size = byte_size(value) result = @@ -1642,7 +1643,7 @@ defmodule Towerops.Workers.DevicePollerWorker do nil end - defp decode_snmp_value(other) do + def decode_snmp_value(other) do Logger.warning("Unexpected SNMP value type: #{inspect(other)}") nil end @@ -1886,7 +1887,8 @@ defmodule Towerops.Workers.DevicePollerWorker do } end - defp get_poll_interval(device) do + @doc false + def get_poll_interval(device) do min_interval = Application.get_env(:towerops, :snmp_min_poll_interval, 300) max(device.check_interval_seconds || @default_poll_interval, min_interval) end @@ -1902,7 +1904,8 @@ defmodule Towerops.Workers.DevicePollerWorker do |> Oban.insert() end - defp should_skip_poll?(device, poll_interval_seconds, grace_period_seconds) do + @doc false + def should_skip_poll?(device, poll_interval_seconds, grace_period_seconds) do case device.last_snmp_poll_at do nil -> false diff --git a/lib/towerops/workers/netbox_sync_worker.ex b/lib/towerops/workers/netbox_sync_worker.ex index dcd39d94..4dff455f 100644 --- a/lib/towerops/workers/netbox_sync_worker.ex +++ b/lib/towerops/workers/netbox_sync_worker.ex @@ -48,7 +48,8 @@ defmodule Towerops.Workers.NetBoxSyncWorker do end end - defp should_sync?(integration) do + @doc false + def should_sync?(integration) do case integration.last_synced_at do nil -> true diff --git a/lib/towerops/workers/sonar_sync_worker.ex b/lib/towerops/workers/sonar_sync_worker.ex index b77e5c7e..346bbc7d 100644 --- a/lib/towerops/workers/sonar_sync_worker.ex +++ b/lib/towerops/workers/sonar_sync_worker.ex @@ -42,7 +42,8 @@ defmodule Towerops.Workers.SonarSyncWorker do end end - defp should_sync?(integration) do + @doc false + def should_sync?(integration) do case integration.last_synced_at do nil -> true diff --git a/lib/towerops/workers/splynx_sync_worker.ex b/lib/towerops/workers/splynx_sync_worker.ex index e96f5b55..de90b75a 100644 --- a/lib/towerops/workers/splynx_sync_worker.ex +++ b/lib/towerops/workers/splynx_sync_worker.ex @@ -42,7 +42,8 @@ defmodule Towerops.Workers.SplynxSyncWorker do end end - defp should_sync?(integration) do + @doc false + def should_sync?(integration) do case integration.last_synced_at do nil -> true diff --git a/lib/towerops/workers/uisp_sync_worker.ex b/lib/towerops/workers/uisp_sync_worker.ex index 98c8fbca..735eaf03 100644 --- a/lib/towerops/workers/uisp_sync_worker.ex +++ b/lib/towerops/workers/uisp_sync_worker.ex @@ -73,7 +73,8 @@ defmodule Towerops.Workers.UispSyncWorker do end end - defp should_sync?(integration) do + @doc false + def should_sync?(integration) do case integration.last_synced_at do nil -> true diff --git a/lib/towerops/workers/visp_sync_worker.ex b/lib/towerops/workers/visp_sync_worker.ex index d361b45f..b102c904 100644 --- a/lib/towerops/workers/visp_sync_worker.ex +++ b/lib/towerops/workers/visp_sync_worker.ex @@ -42,7 +42,8 @@ defmodule Towerops.Workers.VispSyncWorker do end end - defp should_sync?(integration) do + @doc false + def should_sync?(integration) do case integration.last_synced_at do nil -> true diff --git a/lib/towerops_web/controllers/api/mobile_controller.ex b/lib/towerops_web/controllers/api/mobile_controller.ex index d9e6ecc2..e2e5c8b5 100644 --- a/lib/towerops_web/controllers/api/mobile_controller.ex +++ b/lib/towerops_web/controllers/api/mobile_controller.ex @@ -295,30 +295,28 @@ defmodule ToweropsWeb.Api.MobileController do } end - defp format_alert_status(alert) do - cond do - alert.resolved_at != nil -> "resolved" - alert.acknowledged_at != nil -> "acknowledged" - true -> "active" - end + @doc false + def format_alert_status(%{resolved_at: t}) when not is_nil(t), do: "resolved" + def format_alert_status(%{acknowledged_at: t}) when not is_nil(t), do: "acknowledged" + def format_alert_status(_), do: "active" + + @doc false + def timeticks_to_string(timeticks) do + seconds = div(timeticks, 100) + days = div(seconds, 86_400) + hours = div(rem(seconds, 86_400), 3600) + minutes = div(rem(seconds, 3600), 60) + + format_uptime_components(days, hours, minutes) end + defp format_uptime_components(days, hours, _min) when days > 0, do: "#{days}d #{hours}h" + defp format_uptime_components(_days, hours, minutes) when hours > 0, do: "#{hours}h #{minutes}m" + defp format_uptime_components(_, _, minutes), do: "#{minutes}m" + defp format_uptime(device) do if device.snmp_device && device.snmp_device.sys_uptime do timeticks_to_string(device.snmp_device.sys_uptime) end end - - defp timeticks_to_string(timeticks) do - seconds = div(timeticks, 100) - days = div(seconds, 86_400) - hours = div(rem(seconds, 86_400), 3600) - minutes = div(rem(seconds, 3600), 60) - - cond do - days > 0 -> "#{days}d #{hours}h" - hours > 0 -> "#{hours}h #{minutes}m" - true -> "#{minutes}m" - end - end end diff --git a/lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex index da8ed8aa..4da884f0 100644 --- a/lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex @@ -87,7 +87,8 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do end end - defp parse_signature_header(header) do + @doc false + def parse_signature_header(header) do parts = String.split(header, ",") with {:ok, timestamp} <- extract_part(parts, "t="), @@ -112,20 +113,20 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do end end - defp check_timestamp(timestamp_str) do + @doc false + def check_timestamp(timestamp_str) do case Integer.parse(timestamp_str) do {timestamp, ""} -> now = System.system_time(:second) age = now - timestamp - - cond do - age < 0 -> {:error, :timestamp_in_future} - age > @max_age_seconds -> {:error, :timestamp_expired} - true -> :ok - end + classify_age(age) _ -> {:error, :invalid_timestamp} end end + + defp classify_age(age) when age < 0, do: {:error, :timestamp_in_future} + defp classify_age(age) when age > @max_age_seconds, do: {:error, :timestamp_expired} + defp classify_age(_), do: :ok end diff --git a/lib/towerops_web/live/activity_feed_live.ex b/lib/towerops_web/live/activity_feed_live.ex index c52b55fa..ac548888 100644 --- a/lib/towerops_web/live/activity_feed_live.ex +++ b/lib/towerops_web/live/activity_feed_live.ex @@ -172,41 +172,46 @@ defmodule ToweropsWeb.ActivityFeedLive do defp filter_active_class(:device_added), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400" defp filter_active_class(_), do: "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300" - defp severity_dot_color(:critical, _), do: "bg-red-500" - defp severity_dot_color(:warning, _), do: "bg-yellow-500" - defp severity_dot_color(:info, :alert_resolved), do: "bg-green-500" - defp severity_dot_color(:info, :sync), do: "bg-blue-500" - defp severity_dot_color(:info, :device_added), do: "bg-cyan-500" - defp severity_dot_color(:info, _), do: "bg-gray-400" - defp severity_dot_color(_, _), do: "bg-gray-400" + @doc false + def severity_dot_color(:critical, _), do: "bg-red-500" + def severity_dot_color(:warning, _), do: "bg-yellow-500" + def severity_dot_color(:info, :alert_resolved), do: "bg-green-500" + def severity_dot_color(:info, :sync), do: "bg-blue-500" + def severity_dot_color(:info, :device_added), do: "bg-cyan-500" + def severity_dot_color(:info, _), do: "bg-gray-400" + def severity_dot_color(_, _), do: "bg-gray-400" - defp severity_icon_color(:critical, _), do: "text-red-500 dark:text-red-400" - defp severity_icon_color(:warning, _), do: "text-yellow-500 dark:text-yellow-400" - defp severity_icon_color(:info, :alert_resolved), do: "text-green-500 dark:text-green-400" - defp severity_icon_color(:info, :sync), do: "text-blue-500 dark:text-blue-400" - defp severity_icon_color(:info, :device_added), do: "text-cyan-500 dark:text-cyan-400" - defp severity_icon_color(_, _), do: "text-gray-400 dark:text-gray-500" + @doc false + def severity_icon_color(:critical, _), do: "text-red-500 dark:text-red-400" + def severity_icon_color(:warning, _), do: "text-yellow-500 dark:text-yellow-400" + def severity_icon_color(:info, :alert_resolved), do: "text-green-500 dark:text-green-400" + def severity_icon_color(:info, :sync), do: "text-blue-500 dark:text-blue-400" + def severity_icon_color(:info, :device_added), do: "text-cyan-500 dark:text-cyan-400" + def severity_icon_color(_, _), do: "text-gray-400 dark:text-gray-500" - defp severity_text_color(:critical, _), do: "text-red-700 dark:text-red-400" - defp severity_text_color(:warning, _), do: "text-yellow-700 dark:text-yellow-400" - defp severity_text_color(:info, :alert_resolved), do: "text-green-700 dark:text-green-400" - defp severity_text_color(_, _), do: "text-gray-900 dark:text-white" + @doc false + def severity_text_color(:critical, _), do: "text-red-700 dark:text-red-400" + def severity_text_color(:warning, _), do: "text-yellow-700 dark:text-yellow-400" + def severity_text_color(:info, :alert_resolved), do: "text-green-700 dark:text-green-400" + def severity_text_color(_, _), do: "text-gray-900 dark:text-white" - defp type_badge_class(:config_change), do: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" - defp type_badge_class(:alert_fired), do: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" - defp type_badge_class(:alert_resolved), do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - defp type_badge_class(:device_event), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400" - defp type_badge_class(:sync), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" - defp type_badge_class(:device_added), do: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400" - defp type_badge_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + @doc false + def type_badge_class(:config_change), do: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" + def type_badge_class(:alert_fired), do: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + def type_badge_class(:alert_resolved), do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + def type_badge_class(:device_event), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400" + def type_badge_class(:sync), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" + def type_badge_class(:device_added), do: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400" + def type_badge_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" - defp type_label(:config_change), do: "Config" - defp type_label(:alert_fired), do: "Alert" - defp type_label(:alert_resolved), do: "Resolved" - defp type_label(:device_event), do: "Event" - defp type_label(:sync), do: "Sync" - defp type_label(:device_added), do: "Device" - defp type_label(_), do: "Other" + @doc false + def type_label(:config_change), do: "Config" + def type_label(:alert_fired), do: "Alert" + def type_label(:alert_resolved), do: "Resolved" + def type_label(:device_event), do: "Event" + def type_label(:sync), do: "Sync" + def type_label(:device_added), do: "Device" + def type_label(_), do: "Other" defp load_activity(socket) do org_id = socket.assigns.current_scope.organization.id diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex index 43b03888..efeea1da 100644 --- a/lib/towerops_web/live/alert_live/index.ex +++ b/lib/towerops_web/live/alert_live/index.ex @@ -216,42 +216,39 @@ defmodule ToweropsWeb.AlertLive.Index do |> assign(:counts, counts) end - defp filter_alerts(alerts, "all"), do: alerts + @doc false + def filter_alerts(alerts, "all"), do: alerts - defp filter_alerts(alerts, "critical"), + def filter_alerts(alerts, "critical"), do: Enum.filter(alerts, &(&1.alert_type == "device_down" and is_nil(&1.resolved_at))) - defp filter_alerts(alerts, "unresolved"), do: Enum.filter(alerts, &is_nil(&1.resolved_at)) + def filter_alerts(alerts, "unresolved"), do: Enum.filter(alerts, &is_nil(&1.resolved_at)) - defp filter_alerts(alerts, "resolved"), do: Enum.filter(alerts, &(not is_nil(&1.resolved_at))) + def filter_alerts(alerts, "resolved"), do: Enum.filter(alerts, &(not is_nil(&1.resolved_at))) - defp filter_alerts(alerts, _), do: alerts + def filter_alerts(alerts, _), do: alerts - defp sort_alerts(alerts, "age", _site_subs) do + @doc false + def sort_alerts(alerts, "age", _site_subs) do Enum.sort_by(alerts, & &1.triggered_at, {:asc, DateTime}) end - defp sort_alerts(alerts, "impact", site_subs) do + def sort_alerts(alerts, "impact", site_subs) do Enum.sort_by(alerts, fn alert -> sub_count = get_subscriber_count(alert, site_subs) -sub_count end) end - defp sort_alerts(alerts, _severity, _site_subs) do - # Default: severity sort — device_down unresolved first, then by age - Enum.sort_by(alerts, fn alert -> - severity_weight = - cond do - alert.alert_type == "device_down" and is_nil(alert.resolved_at) -> 0 - alert.alert_type == "device_down" -> 1 - true -> 2 - end - - {severity_weight, alert.triggered_at} - end) + def sort_alerts(alerts, _severity, _site_subs) do + Enum.sort_by(alerts, &{severity_weight(&1), &1.triggered_at}) end + @doc false + def severity_weight(%{alert_type: "device_down", resolved_at: nil}), do: 0 + def severity_weight(%{alert_type: "device_down"}), do: 1 + def severity_weight(_alert), do: 2 + defp group_by_site(alerts, site_subscribers) do alerts |> Enum.group_by(fn alert -> @@ -278,11 +275,12 @@ defmodule ToweropsWeb.AlertLive.Index do |> Enum.sort_by(fn group -> {-group.critical_count, -group.total_subscribers, -group.total_count} end) end - defp get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count - defp get_site_subscriber_count(_), do: 0 + @doc false + def get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count + def get_site_subscriber_count(_), do: 0 - defp get_subscriber_count(alert, site_subs) do - # Use gaiia_impact on alert first, fall back to site-level data + @doc false + def get_subscriber_count(alert, site_subs) do cond do alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] -> alert.gaiia_impact["total_subscribers"] @@ -306,38 +304,36 @@ defmodule ToweropsWeb.AlertLive.Index do end @doc false - def severity_color(alert) do - cond do - alert.resolved_at -> - "gray" + def severity_color(%{resolved_at: ra}) when not is_nil(ra), do: "gray" - alert.alert_type == "device_down" and is_nil(alert.resolved_at) -> - age_minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute) - - cond do - age_minutes > 60 -> "red" - age_minutes > 15 -> "orange" - true -> "yellow" - end - - true -> - "green" - end + def severity_color(%{alert_type: "device_down", triggered_at: triggered_at}) do + DateTime.utc_now() + |> DateTime.diff(triggered_at, :minute) + |> age_severity_color() end + def severity_color(_alert), do: "green" + + @doc false + def age_severity_color(age_minutes) when age_minutes > 60, do: "red" + def age_severity_color(age_minutes) when age_minutes > 15, do: "orange" + def age_severity_color(_age_minutes), do: "yellow" + @doc false def age_text(alert) do - minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute) - - cond do - minutes < 1 -> "just now" - minutes < 60 -> "#{minutes}m ago" - minutes < 1440 -> "#{div(minutes, 60)}h ago" - true -> "#{div(minutes, 1440)}d ago" - end + DateTime.utc_now() + |> DateTime.diff(alert.triggered_at, :minute) + |> format_age_minutes() end - defp format_number(number) when is_integer(number) do + @doc false + def format_age_minutes(minutes) when minutes < 1, do: "just now" + def format_age_minutes(minutes) when minutes < 60, do: "#{minutes}m ago" + def format_age_minutes(minutes) when minutes < 1440, do: "#{div(minutes, 60)}h ago" + def format_age_minutes(minutes), do: "#{div(minutes, 1440)}d ago" + + @doc false + def format_number(number) when is_integer(number) do number |> Integer.to_string() |> String.graphemes() @@ -347,21 +343,25 @@ defmodule ToweropsWeb.AlertLive.Index do |> String.reverse() end - defp format_number(number), do: to_string(number) + def format_number(number), do: to_string(number) @doc false def duration_text(alert) do end_time = alert.resolved_at || DateTime.utc_now() - minutes = DateTime.diff(end_time, alert.triggered_at, :minute) - cond do - minutes < 1 -> "<1m" - minutes < 60 -> "#{minutes}m" - minutes < 1440 -> "#{div(minutes, 60)}h #{rem(minutes, 60)}m" - true -> "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h" - end + end_time + |> DateTime.diff(alert.triggered_at, :minute) + |> format_duration_minutes() end + @doc false + def format_duration_minutes(minutes) when minutes < 1, do: "<1m" + def format_duration_minutes(minutes) when minutes < 60, do: "#{minutes}m" + + def format_duration_minutes(minutes) when minutes < 1440, do: "#{div(minutes, 60)}h #{rem(minutes, 60)}m" + + def format_duration_minutes(minutes), do: "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h" + defp severity_badge_class(alert) do color = severity_color(alert) diff --git a/lib/towerops_web/live/capacity_live.ex b/lib/towerops_web/live/capacity_live.ex deleted file mode 100644 index 21620e36..00000000 --- a/lib/towerops_web/live/capacity_live.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule ToweropsWeb.CapacityLive do - @moduledoc false - use ToweropsWeb, :live_view - - alias Towerops.Capacity - - @impl true - def mount(_params, _session, socket) do - organization = socket.assigns.current_scope.organization - summaries = Capacity.get_organization_capacity_summary(organization.id) - - total_capacity = Enum.reduce(summaries, 0, fn s, acc -> acc + s.total_capacity_bps end) - total_throughput = Enum.reduce(summaries, 0.0, fn s, acc -> acc + s.total_throughput_bps end) - - overall_utilization = - if total_capacity > 0, do: Float.round(total_throughput / total_capacity * 100, 1), else: 0.0 - - sites_at_risk = Enum.count(summaries, fn s -> s.utilization_pct >= 75 end) - sites_with_headroom = Enum.count(summaries, fn s -> s.utilization_pct < 70 and s.total_capacity_bps > 0 end) - - {:ok, - socket - |> assign(:page_title, "Capacity") - |> assign(:summaries, summaries) - |> assign(:total_capacity, total_capacity) - |> assign(:total_throughput, total_throughput) - |> assign(:overall_utilization, overall_utilization) - |> assign(:sites_at_risk, sites_at_risk) - |> assign(:sites_with_headroom, sites_with_headroom)} - end - - defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, - do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps" - - defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps" - - defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps" - - defp format_capacity(bps) when is_number(bps), do: "#{bps} bps" - defp format_capacity(_), do: "-" - - defp utilization_color(pct) when pct >= 90, do: "bg-red-500" - defp utilization_color(pct) when pct >= 70, do: "bg-yellow-500" - defp utilization_color(_pct), do: "bg-green-500" - - defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" - defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" - defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" - - defp status_badge(pct) when pct >= 90, do: {"Critical", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"} - - defp status_badge(pct) when pct >= 70, - do: {"Warning", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"} - - defp status_badge(_pct), do: {"Healthy", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"} -end diff --git a/lib/towerops_web/live/capacity_live.html.heex b/lib/towerops_web/live/capacity_live.html.heex deleted file mode 100644 index 0450264f..00000000 --- a/lib/towerops_web/live/capacity_live.html.heex +++ /dev/null @@ -1,137 +0,0 @@ - - <.breadcrumb items={[ - %{label: "Dashboard", navigate: ~p"/dashboard"}, - %{label: "Capacity"} - ]} /> - -
-

{t("Network Capacity")}

-

- {t("Backhaul capacity utilization across all sites")} -

-
- - -
-
-
- {t("Total Capacity")} -
-
- {format_capacity(@total_capacity)} -
-
-
-
- {t("Current Throughput")} -
-
- {format_capacity(@total_throughput)} -
-
-
-
- {t("Sites at Risk")} -
-
- {@sites_at_risk} -
-
-
-
- {t("Sites with Headroom")} -
-
- {@sites_with_headroom} -
-
-
- - - <%= if @summaries != [] do %> -
-
-

- {t("Site Capacity")} -

-
-
- - - - - - - - - - - - - <%= for summary <- Enum.sort_by(@summaries, & &1.utilization_pct, :desc) do %> - - - - - - - - - <% end %> - -
{t("Site")}{t("Capacity")}{t("Throughput")}{t("Utilization")}{t("Status")}{t("Interfaces")}
- <.link - navigate={~p"/sites/#{summary.site_id}"} - class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {summary.site_name} - - - {format_capacity(summary.total_capacity_bps)} - - {format_capacity(summary.total_throughput_bps)} - -
-
-
-
-
- - {Float.round(summary.utilization_pct, 1)}% - -
-
- <% {label, classes} = status_badge(summary.utilization_pct) %> - - {label} - - - {length(summary.interfaces)} -
-
-
- <% else %> -
- <.icon name="hero-signal" class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600" /> -

- {t("No capacity data")} -

-

- {t("Configure capacity on device interfaces to see utilization data here.")} -

-
- <% end %> -
diff --git a/lib/towerops_web/live/check_live/form_component.ex b/lib/towerops_web/live/check_live/form_component.ex index 2eab375e..78b57699 100644 --- a/lib/towerops_web/live/check_live/form_component.ex +++ b/lib/towerops_web/live/check_live/form_component.ex @@ -411,7 +411,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do end end - defp config_to_form_fields(config, "http") do + @doc false + def config_to_form_fields(config, "http") do %{ "url" => config["url"] || "", "method" => config["method"] || "GET", @@ -422,7 +423,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } end - defp config_to_form_fields(config, "tcp") do + def config_to_form_fields(config, "tcp") do %{ "host" => config["host"] || "", "port" => to_string(config["port"] || ""), @@ -431,7 +432,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } end - defp config_to_form_fields(config, "dns") do + def config_to_form_fields(config, "dns") do %{ "hostname" => config["hostname"] || "", "record_type" => config["record_type"] || "A", @@ -440,7 +441,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } end - defp config_to_form_fields(config, "ssl") do + def config_to_form_fields(config, "ssl") do %{ "host" => config["host"] || "", "port" => to_string(config["port"] || 443), @@ -448,13 +449,24 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } end - defp config_to_form_fields(_config, _type), do: %{} + def config_to_form_fields(_config, _type), do: %{} defp assign_default_config(socket, type) do assign(socket, :default_config, default_config_for_type(socket, type)) end - defp default_config_for_type(_socket, "http") do + defp default_config_for_type(_socket, "http"), do: default_config_by_type("http", "") + + defp default_config_for_type(socket, "tcp"), do: default_config_by_type("tcp", device_host_from_socket(socket)) + + defp default_config_for_type(_socket, "dns"), do: default_config_by_type("dns", "") + + defp default_config_for_type(socket, "ssl"), do: default_config_by_type("ssl", device_host_from_socket(socket)) + + defp default_config_for_type(_socket, _type), do: %{} + + @doc false + def default_config_by_type("http", _host) do %{ "url" => "", "method" => "GET", @@ -464,32 +476,31 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } end - defp default_config_for_type(socket, "tcp") do - %{"host" => device_host(socket), "port" => ""} + def default_config_by_type("tcp", host), do: %{"host" => host, "port" => ""} + def default_config_by_type("dns", _host), do: %{"hostname" => "", "record_type" => "A"} + + def default_config_by_type("ssl", host), do: %{"host" => host, "port" => "443", "warning_days" => "30"} + + def default_config_by_type(_type, _host), do: %{} + + @doc false + def device_host(nil), do: "" + def device_host(%{ip_address: nil}), do: "" + def device_host(%{ip_address: ip}), do: to_string(ip) + def device_host(_), do: "" + + defp device_host_from_socket(socket) do + device_host(socket.assigns[:device]) end - defp default_config_for_type(_socket, "dns") do - %{"hostname" => "", "record_type" => "A"} - end - - defp default_config_for_type(socket, "ssl") do - %{"host" => device_host(socket), "port" => "443", "warning_days" => "30"} - end - - defp default_config_for_type(_socket, _type), do: %{} - - defp device_host(socket) do - device = socket.assigns[:device] - if device && device.ip_address, do: to_string(device.ip_address), else: "" - end - - defp merge_config_params(check_params, type) do + @doc false + def merge_config_params(check_params, type) do check_params |> Map.put("config", config_from_params(check_params, type)) |> Map.put("check_type", type) end - defp config_from_params(check_params, "http") do + def config_from_params(check_params, "http") do maybe_add( %{ "url" => check_params["url"], @@ -503,7 +514,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do ) end - defp config_from_params(check_params, "tcp") do + def config_from_params(check_params, "tcp") do %{ "host" => check_params["host"], "port" => String.to_integer(check_params["port"] || "0") @@ -512,7 +523,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do |> maybe_add("expect", check_params["expect_string"]) end - defp config_from_params(check_params, "dns") do + def config_from_params(check_params, "dns") do %{ "hostname" => check_params["hostname"], "record_type" => check_params["record_type"] || "A" @@ -521,7 +532,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do |> maybe_add("expected", check_params["expected_result"]) end - defp config_from_params(check_params, "ssl") do + def config_from_params(check_params, "ssl") do %{ "host" => check_params["host"], "port" => String.to_integer(check_params["port"] || "443"), @@ -529,7 +540,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } end - defp config_from_params(_check_params, _type), do: %{} + def config_from_params(_check_params, _type), do: %{} defp maybe_add(map, _key, nil), do: map defp maybe_add(map, _key, ""), do: map diff --git a/lib/towerops_web/live/config_timeline_live.ex b/lib/towerops_web/live/config_timeline_live.ex index 91c71b18..d80de081 100644 --- a/lib/towerops_web/live/config_timeline_live.ex +++ b/lib/towerops_web/live/config_timeline_live.ex @@ -256,7 +256,8 @@ defmodule ToweropsWeb.ConfigTimelineLive do Monitoring.get_device_check_data(device_id, since) end - defp timeline_event(event) do + @doc false + def timeline_event(event) do %{ id: event.id, t: DateTime.to_iso8601(event.changed_at), @@ -265,7 +266,8 @@ defmodule ToweropsWeb.ConfigTimelineLive do } end - defp change_size_class(size) when size > 50, do: "badge-error" - defp change_size_class(size) when size > 20, do: "badge-warning" - defp change_size_class(_), do: "badge-info" + @doc false + def change_size_class(size) when is_integer(size) and size > 50, do: "badge-error" + def change_size_class(size) when is_integer(size) and size > 20, do: "badge-warning" + def change_size_class(_), do: "badge-info" end diff --git a/lib/towerops_web/live/device_live/components/backups_tab.ex b/lib/towerops_web/live/device_live/components/backups_tab.ex deleted file mode 100644 index ff4b5fda..00000000 --- a/lib/towerops_web/live/device_live/components/backups_tab.ex +++ /dev/null @@ -1,315 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.BackupsTab do - @moduledoc """ - LiveComponent for the device MikroTik backups tab. - - Displays MikroTik configuration backups with download, compare, - and backup-now functionality. - """ - use ToweropsWeb, :live_component - - alias ToweropsWeb.DeviceLive.Helpers.DataLoaders - alias ToweropsWeb.DeviceLive.Helpers.Formatters - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_backups_data()} - end - - defp load_backups_data(socket) do - device = socket.assigns.device - mikrotik_backups = DataLoaders.load_mikrotik_backups(device) - - socket - |> assign(:mikrotik_backups, mikrotik_backups) - |> assign(:selected_backup_ids, MapSet.new()) - end - - @impl true - def render(assigns) do - ~H""" -
- <%= if false and @device.mikrotik_enabled do %> -
-
-
-
-

- {t("Configuration Backups")} - <%= if Enum.any?(@mikrotik_backups) do %> - - ({length(@mikrotik_backups)}) - - <% end %> -

- - <.icon name="hero-beaker" class="h-3 w-3" /> Experimental - -
- <%= if @agent_info.agent_token_id do %> - - <% else %> -
- {t("Assign an agent to enable backups")} -
- <% end %> -
-
- <%= if Enum.any?(@mikrotik_backups) do %> - <%!-- Instructions for comparing backups --%> - <%= if length(@mikrotik_backups) > 1 do %> -
-
- <.icon - name="hero-information-circle" - class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" - /> -
- Compare configurations: - {t("Select any 2 backups using the checkboxes to compare their differences")} -
-
-
- <% end %> -
- - - - - - - - - - - - - - <%= for backup <- @mikrotik_backups do %> - <% compression_ratio = - if backup.config_size_bytes && backup.config_size_bytes > 0 do - Float.round( - (1 - backup.compressed_size_bytes / backup.config_size_bytes) * 100, - 1 - ) - else - 0.0 - end %> - - - - - - - - - - <% end %> - -
- <.icon name="hero-check-circle" class="h-4 w-4 mx-auto" /> - DateOriginal SizeCompressed SizeRatioSourceActions
- = 2 - } - title={ - if MapSet.member?(@selected_backup_ids, backup.id), - do: "Selected for comparison", - else: - if(MapSet.size(@selected_backup_ids) >= 2, - do: "Maximum 2 backups can be selected", - else: "Select to compare" - ) - } - class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700" - /> - - {ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)} - - {format_bytes(backup.config_size_bytes)} - - {format_bytes(backup.compressed_size_bytes)} - - {compression_ratio}% - - - {String.replace(backup.trigger_source, "_", " ") |> String.capitalize()} - - -
- - <%= if ToweropsWeb.Permissions.owner?(@current_scope) do %> - <.button - phx-click="delete_backup" - phx-value-id={backup.id} - data-confirm={ - t( - "Are you sure you want to delete this backup? This action cannot be undone." - ) - } - variant="danger" - > - <.icon name="hero-trash" class="h-4 w-4" /> Delete - - <% end %> -
-
-
- <%!-- Pagination --%> - <%= if assigns[:pagination] do %> -
- <.pagination - meta={@pagination} - path={~p"/devices/#{@device.id}"} - params={%{"tab" => "backups"}} - /> -
- <% end %> - <%!-- Floating Compare Button --%> - <%= if MapSet.size(@selected_backup_ids) > 0 do %> -
-
-
- <%!-- Selection status --%> -
- <.icon - name="hero-check-circle" - class="h-5 w-5 text-blue-600 dark:text-blue-400" - /> -
- {MapSet.size(@selected_backup_ids)} - of 2 backups selected -
-
- <%!-- Instructions when only 1 selected --%> - <%= if MapSet.size(@selected_backup_ids) == 1 do %> -
- {t("Select one more backup to compare")} -
- <% end %> - <%!-- Action buttons --%> -
- - -
-
-
-
- <% end %> - <% else %> -
- <.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" /> -

- {t("No backups yet")} -

- <%= if @agent_info.agent_token_id do %> -

- Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour( - 7, - @timezone - )}. -

- <% else %> -

- {t("Assign an agent to enable automatic backups.")} -

- <% end %> -
- <% end %> -
- <% else %> -
-
- <.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" /> -

- {t("MikroTik API not enabled")} -

-

- {t("Enable MikroTik API in device settings to enable configuration backups.")} -

-
- <.button navigate={~p"/devices/#{@device.id}/edit"}> - {t("Edit Device Settings")} - -
-
-
- <% end %> -
- """ - end - - @impl true - def handle_event("toggle_backup_selection", %{"id" => backup_id}, socket) do - selected = socket.assigns.selected_backup_ids - - updated_selected = - if MapSet.member?(selected, backup_id) do - MapSet.delete(selected, backup_id) - else - # Limit to 2 selections max - if MapSet.size(selected) >= 2 do - selected - else - MapSet.put(selected, backup_id) - end - end - - {:noreply, assign(socket, :selected_backup_ids, updated_selected)} - end - - @impl true - def handle_event("clear_selection", _params, socket) do - {:noreply, assign(socket, :selected_backup_ids, MapSet.new())} - end - - # Helper functions - defp format_bytes(bytes), do: Formatters.format_bytes(bytes) -end diff --git a/lib/towerops_web/live/device_live/components/checks_tab.ex b/lib/towerops_web/live/device_live/components/checks_tab.ex deleted file mode 100644 index be9c8336..00000000 --- a/lib/towerops_web/live/device_live/components/checks_tab.ex +++ /dev/null @@ -1,292 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.ChecksTab do - @moduledoc """ - LiveComponent for the device monitoring checks tab. - - Displays all monitoring checks for the device grouped by type - (SNMP, HTTP, TCP, DNS, SSL, ping). - """ - use ToweropsWeb, :live_component - - alias Towerops.Monitoring - alias ToweropsWeb.DeviceLive.Helpers.DataLoaders - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_checks_data()} - end - - defp load_checks_data(socket) do - device_id = socket.assigns.device.id - organization_id = socket.assigns.device.organization_id - checks_data = DataLoaders.load_checks_data(device_id, organization_id) - - socket - |> assign(:checks, checks_data.checks) - |> assign(:grouped_checks, checks_data.grouped_checks) - end - - @impl true - def render(assigns) do - ~H""" -
- <%= if Enum.empty?(@checks) do %> - -
-
- <.icon name="hero-clipboard-document-check" class="mx-auto h-12 w-12 text-gray-400" /> -

- {t("No checks configured")} -

-

- {t( - "Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually." - )} -

-
- <%= if @device.snmp_enabled do %> - <.button type="button" phx-click="run_discovery"> - <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Run Discovery - - <% end %> - <.button type="button" phx-click="add_check"> - <.icon name="hero-plus" class="h-4 w-4" /> Add Check - -
-
-
- <% else %> - -
- -
-

- Checks ({length(@checks)}) -

- <.button type="button" phx-click="add_check"> - <.icon name="hero-plus" class="h-4 w-4" /> Add Check - -
- <%= for {group_key, group_checks} <- @grouped_checks do %> -
- -
-

- {group_title(group_key)} ({length(group_checks)}) -

-
- -
- - - - - - - - - - - - <%= for check <- group_checks do %> - - - - - - - - <% end %> - -
- {t("Name")} - - {t("Status")} - - {t("Value")} - - {t("Last Checked")} - - Actions -
- {check.name} - - {render_status_badge(check.current_state)} - - <%= if check.last_check_at do %> - {get_latest_value(check)} - <% else %> - - Pending - - <% end %> - - <%= if check.last_check_at do %> - {format_relative_time(check.last_check_at)} - <% else %> - - Never - - <% end %> - -
- <.link - navigate={ - ~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h" - } - class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium" - > - {t("Graph")} - - <%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %> - - - <% end %> -
-
-
-
- <% end %> -
- <% end %> -
- """ - end - - # Helper functions - defp group_title(:snmp_sensors), do: "SNMP Sensors" - defp group_title(:snmp_interfaces), do: "SNMP Interfaces" - defp group_title(:snmp_processors), do: "SNMP Processors" - defp group_title(:snmp_storage), do: "SNMP Storage" - defp group_title(:http_checks), do: "HTTP Checks" - defp group_title(:tcp_checks), do: "TCP Checks" - defp group_title(:dns_checks), do: "DNS Checks" - defp group_title(:ssl_checks), do: "SSL Certificate Checks" - defp group_title(:ping_checks), do: "Ping Checks" - defp group_title(:other_checks), do: "Other Checks" - - defp render_status_badge(0) do - assigns = %{} - - ~H""" - - OK - - """ - end - - defp render_status_badge(1) do - assigns = %{} - - ~H""" - - WARNING - - """ - end - - defp render_status_badge(2) do - assigns = %{} - - ~H""" - - CRITICAL - - """ - end - - defp render_status_badge(3) do - assigns = %{} - - ~H""" - - UNKNOWN - - """ - end - - defp render_status_badge(_) do - assigns = %{} - - ~H""" - - PENDING - - """ - end - - defp get_latest_value(check) do - # Get the latest check result to show current value - case Monitoring.get_latest_check_result(check.id) do - nil -> "-" - result -> format_check_value(result, check) - end - end - - defp format_check_value(%{value: nil}, _check), do: "-" - - defp format_check_value(%{value: value}, check) when is_number(value) do - case check.check_type do - "snmp_sensor" -> format_check_sensor_value(value, check.config) - "snmp_processor" -> "#{Float.round(value, 1)}%" - "snmp_storage" -> "#{Float.round(value, 1)}%" - t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms" - _ -> value |> Float.round(2) |> to_string() - end - end - - defp format_check_value(_result, _check), do: "-" - - @sensor_value_formats %{ - "temperature" => {1, "°C"}, - "voltage" => {2, "V"}, - "current" => {2, "A"}, - "power" => {1, "W"}, - "frequency" => {0, "Hz"}, - "humidity" => {1, "%"} - } - - defp format_check_sensor_value(value, %{"sensor_type" => "fanspeed", "sensor_unit" => unit}) do - "#{round(value)}#{unit || " RPM"}" - end - - defp format_check_sensor_value(value, config) do - sensor_type = config["sensor_type"] - unit = config["sensor_unit"] - {precision, default_unit} = Map.get(@sensor_value_formats, sensor_type, {2, ""}) - "#{Float.round(value, precision)}#{unit || default_unit}" - end - - defp format_relative_time(datetime) do - ToweropsWeb.TimeHelpers.format_time_ago(datetime) - end -end diff --git a/lib/towerops_web/live/device_live/components/gaiia_tab.ex b/lib/towerops_web/live/device_live/components/gaiia_tab.ex deleted file mode 100644 index bc2fd6c4..00000000 --- a/lib/towerops_web/live/device_live/components/gaiia_tab.ex +++ /dev/null @@ -1,331 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.GaiiaTab do - @moduledoc """ - LiveComponent for the device Gaiia integration tab. - - Displays Gaiia inventory item data, assigned account, subscriptions, - and network site information. - """ - use ToweropsWeb, :live_component - - alias ToweropsWeb.DeviceLive.Helpers.DataLoaders - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_gaiia_data()} - end - - defp load_gaiia_data(socket) do - device = socket.assigns.device - gaiia_data = DataLoaders.load_gaiia_data(device) - - socket - |> assign(:gaiia_item, gaiia_data.gaiia_item) - |> assign(:gaiia_account, gaiia_data.gaiia_account) - |> assign(:gaiia_subscriptions, gaiia_data.gaiia_subscriptions) - |> assign(:gaiia_network_site, gaiia_data.gaiia_network_site) - end - - @impl true - def render(assigns) do - ~H""" -
- <%= if @gaiia_item do %> -
- <%!-- Inventory Item Details --%> -
-
-

- {t("Gaiia Inventory Item")} -

-
-
-
-
-
Name
-
- {@gaiia_item.name || "---"} -
-
-
-
Gaiia ID
-
- {@gaiia_item.gaiia_id} -
-
-
-
Status
-
- - "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" - - "decommissioned" -> - "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" - - _ -> - "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" - end - ]}> - {@gaiia_item.status || "unknown"} - -
-
- <%= if @gaiia_item.model_name do %> -
-
Model
-
- {@gaiia_item.model_name} -
-
- <% end %> - <%= if @gaiia_item.manufacturer_name do %> -
-
- {t("Manufacturer")} -
-
- {@gaiia_item.manufacturer_name} -
-
- <% end %> - <%= if @gaiia_item.category do %> -
-
- {t("Category")} -
-
- {@gaiia_item.category} -
-
- <% end %> - <%= if @gaiia_item.serial_number do %> -
-
- {t("Serial Number")} -
-
- {@gaiia_item.serial_number} -
-
- <% end %> - <%= if @gaiia_item.mac_address do %> -
-
- {t("MAC Address")} -
-
- {@gaiia_item.mac_address} -
-
- <% end %> - <%= if @gaiia_item.ip_address do %> -
-
- {t("IP Address (Gaiia)")} -
-
- {@gaiia_item.ip_address} - <%= if @device.ip_address && @gaiia_item.ip_address != @device.ip_address do %> - - <.icon name="hero-exclamation-triangle-mini" class="mr-0.5 h-3 w-3" /> - {t("Mismatch")} - - <% end %> -
-
- <% end %> -
-
-
- - <%!-- Network Site --%> - <%= if @gaiia_network_site do %> -
-
-

- {t("Gaiia Network Site")} -

-
-
-
-
-
- {t("Site Name")} -
-
- {@gaiia_network_site.name} -
-
- <%= if @gaiia_network_site.account_count do %> -
-
- {t("Subscribers")} -
-
- {@gaiia_network_site.account_count} -
-
- <% end %> - <%= if @gaiia_network_site.total_mrr do %> -
-
- {t("Monthly Revenue")} -
-
- ${Decimal.round(@gaiia_network_site.total_mrr, 2)} -
-
- <% end %> -
-
-
- <% end %> - - <%!-- Linked Subscriber --%> - <%= if @gaiia_account do %> -
-
-

- {t("Linked Subscriber")} -

-
-
-
-
-
Name
-
- {@gaiia_account.name} -
-
- <%= if @gaiia_account.readable_id do %> -
-
- {t("Account ID")} -
-
- {@gaiia_account.readable_id} -
-
- <% end %> -
-
Status
-
- - "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" - - "suspended" -> - "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" - - _ -> - "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" - end - ]}> - {@gaiia_account.status || "unknown"} - -
-
- <%= if @gaiia_account.mrr do %> -
-
MRR
-
- ${Decimal.round(@gaiia_account.mrr, 2)} -
-
- <% end %> -
-
-
- <% end %> - - <%!-- Billing Subscriptions --%> - <%= if @gaiia_subscriptions != [] do %> -
-
-

- {t("Billing Subscriptions")} -

-
-
- - - - - - - - - - <%= for sub <- @gaiia_subscriptions do %> - - - - - - <% end %> - -
- {t("Plan")} - - {t("Status")} - - {t("MRR")} -
- {sub.product_name || "---"} - - - "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" - - "suspended" -> - "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" - - _ -> - "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" - end - ]}> - {sub.status || "unknown"} - - - <%= if sub.mrr_amount do %> - ${Decimal.round(sub.mrr_amount, 2)} - - {sub.currency || ""} - - <% else %> - --- - <% end %> -
-
-
- <% end %> -
- <% else %> -
-

- {t("No Gaiia inventory item linked to this device.")} -

-
- <% end %> -
- """ - end -end diff --git a/lib/towerops_web/live/device_live/components/overview_tab.ex b/lib/towerops_web/live/device_live/components/overview_tab.ex deleted file mode 100644 index 61add9ae..00000000 --- a/lib/towerops_web/live/device_live/components/overview_tab.ex +++ /dev/null @@ -1,1085 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.OverviewTab do - @moduledoc """ - LiveComponent for the device overview tab. - - Displays device metrics, charts (latency, CPU, memory, storage, traffic), - and categorized sensors (temperature, voltage, power, etc.). - """ - use ToweropsWeb, :live_component - - alias Towerops.Devices.VersionComparator - alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders - alias ToweropsWeb.DeviceLive.Helpers.DataLoaders - alias ToweropsWeb.DeviceLive.Helpers.Formatters - alias ToweropsWeb.DeviceLive.Helpers.SensorClassifiers - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_overview_data()} - end - - defp load_overview_data(socket) do - device = socket.assigns.device - device_id = device.id - snmp_device = socket.assigns.snmp_device - sensors = socket.assigns.snmp_sensors - - # Load processors and storage - processors = DataLoaders.load_processors(snmp_device) - storage = DataLoaders.load_storage(snmp_device) - - # Build chart data - latency_chart_data = ChartBuilders.load_latency_chart_data(device_id) - processor_chart_data = ChartBuilders.load_processor_chart_data(processors) - memory_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["memory_usage"]) - storage_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["disk_usage"]) - storage_volume_chart_data = ChartBuilders.load_storage_volume_chart_data(storage) - traffic_chart_data = ChartBuilders.load_overall_traffic_chart_data(snmp_device) - - wireless_chart_data = - ChartBuilders.load_sensor_chart_data(snmp_device, [ - "frequency", - "power", - "rssi", - "ccq", - "clients", - "distance", - "noise-floor", - "quality", - "rate", - "utilization" - ]) - - # Classify sensors by type - {transceiver_sensors, non_transceiver_sensors} = - SensorClassifiers.split_transceiver_sensors(sensors) - - temperature_sensors = - Enum.filter(non_transceiver_sensors, &SensorClassifiers.temperature_sensor?/1) - - voltage_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.voltage_sensor?/1) - current_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.current_sensor?/1) - power_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.power_sensor?/1) - fan_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.fan_sensor?/1) - load_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.load_sensor?/1) - storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"])) - count_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.counter_sensor?/1) - - {firewall_counters, general_counters} = - Enum.split_with(count_sensors, &SensorClassifiers.firewall_counter?/1) - - wireless_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.wireless_sensor?/1) - signal_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.signal_sensor?/1) - grouped_transceivers = SensorClassifiers.group_transceiver_sensors(transceiver_sensors) - metrics = DataLoaders.calculate_metrics([], device) - available_firmware = DataLoaders.get_available_firmware(snmp_device) - connected_devices = DataLoaders.load_connected_devices(device_id) - recent_config_changes = DataLoaders.load_recent_config_changes(device) - - socket - |> assign(:metrics, metrics) - |> assign(:processors, processors) - |> assign(:storage, storage) - |> assign(:latency_chart_data, latency_chart_data) - |> assign(:processor_chart_data, processor_chart_data) - |> assign(:memory_chart_data, memory_chart_data) - |> assign(:storage_chart_data, storage_chart_data) - |> assign(:storage_volume_chart_data, storage_volume_chart_data) - |> assign(:traffic_chart_data, traffic_chart_data) - |> assign(:wireless_chart_data, wireless_chart_data) - |> assign(:temperature_sensors, temperature_sensors) - |> assign(:voltage_sensors, voltage_sensors) - |> assign(:current_sensors, current_sensors) - |> assign(:power_sensors, power_sensors) - |> assign(:fan_sensors, fan_sensors) - |> assign(:load_sensors, load_sensors) - |> assign(:storage_sensors, storage_sensors) - |> assign(:count_sensors, count_sensors) - |> assign(:general_counters, general_counters) - |> assign(:firewall_counters, firewall_counters) - |> assign(:wireless_sensors, wireless_sensors) - |> assign(:signal_sensors, signal_sensors) - |> assign(:grouped_transceivers, grouped_transceivers) - |> assign(:available_firmware, available_firmware) - |> assign(:connected_devices, connected_devices) - |> assign(:recent_config_changes, recent_config_changes) - end - - @impl true - def render(assigns) do - ~H""" -
-
- -
- -
-
-

- {t("Device Information")} -

-
-
-
- <%= if @snmp_device do %> -
-
- System Name -
-
- {@snmp_device.sys_name || @device.name} -
-
- -
-
- {t("Resolved IP")} -
-
- {@device.ip_address} - <.icon - name="hero-clipboard-document" - class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/rip:opacity-100 transition-opacity text-gray-400" - /> -
-
- -
-
Hardware
-
- {@snmp_device.model || "N/A"} -
-
- -
-
- Operating System -
-
-
- - {cond do - @snmp_device.manufacturer && @snmp_device.firmware_version -> - "#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}" - - @snmp_device.manufacturer -> - @snmp_device.manufacturer - - true -> - @snmp_device.sys_descr || "N/A" - end} - - <%= if firmware_update_available?(@snmp_device, @available_firmware) do %> -
- <.icon - name="hero-arrow-up-circle" - class="h-5 w-5 text-blue-500 cursor-help dark:text-blue-400" - /> - -
- - -
- <% end %> -
-
-
- -
-
- Serial Number -
-
- {@snmp_device.serial_number} -
-
- -
-
Object ID
-
- {@snmp_device.sys_object_id || "N/A"} -
-
- -
-
Contact
-
- {@snmp_device.sys_contact || "N/A"} -
-
- -
-
Location
-
- {format_location(@snmp_device.sys_location)} -
-
- -
-
Uptime
-
- {format_uptime(@snmp_device.sys_uptime)} -
-
- <% else %> -
-
Name
-
- {@device.name} -
-
- -
-
- IP Address -
-
- {@device.ip_address} -
-
- <% end %> - -
-
- Device Added -
-
- {format_device_age(@device.inserted_at)} -
-
- -
-
- Last Discovered -
-
- {if @device.last_discovery_at do - format_device_age(@device.last_discovery_at) - else - "Never" - end} -
-
- -
-
Last Polled
-
- {if @device.last_snmp_poll_at do - format_device_age(@device.last_snmp_poll_at) - else - "Never" - end} -
-
-
-
-
- - <%= if @traffic_chart_data do %> -
- <.link - navigate={~p"/devices/#{@device.id}/graph/traffic"} - class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" - > -
-

- {t("Overall Traffic")} -

- <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> -
- -
-
-
-
-
-
- {t("Loading chart...")} -
-
- -
-
-
- <% end %> - - <%= if @latency_chart_data do %> -
- <.link - navigate={~p"/devices/#{@device.id}/graph/latency"} - class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" - > -
-

- {t("ICMP Latency")} -

- <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> -
- -
-
-
-
-
-
- {t("Loading chart...")} -
-
- -
-
-
- <% end %> - - <%!-- Recent Config Changes mini-card --%> - <%= if false && @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) do %> -
-
-

- <.icon name="hero-clock" class="h-4 w-4 text-orange-500" /> - {t("Recent Config Changes")} -

- <.link - navigate={~p"/devices/#{@device.id}/config-timeline"} - class="text-xs text-blue-500 hover:underline" - > - View Timeline → - -
-
- <%= for event <- Enum.take(@recent_config_changes, 5) do %> -
-
- - - {Calendar.strftime(event.changed_at, "%b %d %H:%M")} - - - {s} - -
- - {event.change_size} lines - -
- <% end %> -
-
- <% end %> -
- -
- - <%= if @processor_chart_data || (@processors && length(@processors) > 0) do %> - <% processor_count = if @processors, do: length(@processors), else: 0 - - avg_load = - if processor_count > 0 do - loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1) - if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil - else - nil - end %> -
- <.link - navigate={~p"/devices/#{@device.id}/graph/processors"} - class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" - > -
-

- <%= if processor_count > 0 do %> - {processor_count} {if processor_count == 1, - do: "Processor", - else: "Processors"} - <% else %> - {t("Processors")} - <% end %> -

- <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> -
- - - <%= if @processor_chart_data do %> -
-
- -
-
- <% end %> - - <%= if avg_load do %> -
-
- - {t("Average Load")} - -
-
-
= 50 && avg_load < 80 && "bg-yellow-500", - avg_load >= 80 && "bg-red-500" - ]} - style={"width: #{min(avg_load, 100)}%"} - > -
-
- - {Float.round(avg_load, 0)}% - -
-
-
- <% end %> -
- <% end %> - - <%= if @memory_chart_data do %> -
- <.link - navigate={~p"/devices/#{@device.id}/graph/memory"} - class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" - > -
-

- {t("Memory Usage")} -

- <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> -
- -
-
- -
-
-
- <% end %> - - <%= if @storage_sensors && length(@storage_sensors) > 0 do %> -
-
-

- {t("Storage Usage")} -

-
-
-
- <%= for sensor <- @storage_sensors do %> -
-
- <.link - navigate={~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"} - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading do %> - {format_sensor_value( - sensor.latest_reading.value, - sensor.sensor_divisor - )} - {sensor.sensor_unit} - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> -
-
-
- <% end %> - - <%= if @storage && length(@storage) > 0 do %> -
-
-

- {t("Storage Volumes")} - - ({length(@storage)}) - -

-
- - <%= if @storage_volume_chart_data do %> -
-
- -
-
- <% end %> -
- <%= for storage <- @storage do %> - <% usage_percent = - if storage.total_bytes && storage.total_bytes > 0 do - Float.round(storage.used_bytes / storage.total_bytes * 100, 1) - else - 0.0 - end %> - <.link - navigate={ - ~p"/devices/#{@device.id}/graph/storage_volume?storage_id=#{storage.id}" - } - class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1 rounded transition-colors" - > -
- - {storage.description || "Storage #{storage.storage_index}"} - - - {format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)} - -
-
-
-
= 70 && usage_percent < 90 && "bg-yellow-500", - usage_percent >= 90 && "bg-red-500" - ]} - style={"width: #{min(usage_percent, 100)}%"} - > -
-
- - {usage_percent}% - -
-
- {String.replace(storage.storage_type || "other", "_", " ") - |> String.capitalize()} -
- - <% end %> -
-
- <% end %> - - <%= if @temperature_sensors && length(@temperature_sensors) > 0 do %> -
-
-

- {t("Temperature")} -

-
-
-
- <%= for sensor <- @temperature_sensors do %> -
-
- <.link - navigate={ - ~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}" - } - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading do %> - {format_sensor_value( - sensor.latest_reading.value, - sensor.sensor_divisor - )} - {sensor.sensor_unit || "°C"} - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> -
-
-
- <% end %> - - <%= if @voltage_sensors && length(@voltage_sensors) > 0 do %> -
-
-

- {t("Voltage")} -

-
-
-
- <%= for sensor <- @voltage_sensors do %> -
-
- <.link - navigate={~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"} - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading do %> - {format_sensor_value( - sensor.latest_reading.value, - sensor.sensor_divisor - )} - {sensor.sensor_unit} - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> -
-
-
- <% end %> - - <%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %> -
-
-

- {t("Transceivers")} -

-
-
-
- <%= for {_transceiver_name, sensors} <- @grouped_transceivers do %> - <%= for sensor <- sensors do %> -
-
- <.link - navigate={~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"} - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading do %> - {format_sensor_value( - sensor.latest_reading.value, - sensor.sensor_divisor - )} - {sensor.sensor_unit} - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> - <% end %> -
-
-
- <% end %> - - <%= if (@general_counters && length(@general_counters) > 0) || (@firewall_counters && length(@firewall_counters) > 0) do %> -
-
-

- {t("Counters")} -

-
-
- - <%= if @general_counters && length(@general_counters) > 0 do %> -
- <%= for sensor <- @general_counters do %> -
-
- <.link - navigate={ - ~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}" - } - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading && sensor.latest_reading.value do %> - {trunc(sensor.latest_reading.value)} - <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> - {sensor.sensor_unit} - <% end %> - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> -
- <% end %> - - <%= if @firewall_counters && length(@firewall_counters) > 0 do %> -
-

- {t("Firewall")} -

-
- <%= for sensor <- @firewall_counters do %> -
-
- <.link - navigate={ - ~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}" - } - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading && sensor.latest_reading.value do %> - {trunc(sensor.latest_reading.value)} - <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> - {sensor.sensor_unit} - <% end %> - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> -
-
- <% end %> -
-
- <% end %> - - <%= if @wireless_sensors && length(@wireless_sensors) > 0 do %> -
-
-

- {t("Wireless")} -

-
-
-
- <%= for sensor <- @wireless_sensors do %> -
-
- <.link - navigate={~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"} - class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" - > - {sensor.sensor_descr} - -
-
- <%= if sensor.latest_reading && sensor.latest_reading.value do %> - <%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %> - {trunc(sensor.latest_reading.value)} - <% else %> - {:erlang.float_to_binary(sensor.latest_reading.value * 1.0, - decimals: 1 - )} - <% end %> - <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> - {sensor.sensor_unit} - <% end %> - <% else %> - {t("N/A")} - <% end %> -
-
- <% end %> -
-
-
- <% end %> -
-
- <%!-- Connected Devices --%> - <%= if @connected_devices != [] do %> -
-
-

- {t("Connected Devices")} - - ({length(@connected_devices)} links) - -

-
-
- - - - - - - - - - - - - <%= for link <- @connected_devices do %> - <% peer_device = - cond do - link.target_device && link.target_device.id != @device.id -> - link.target_device - - link.source_device && link.source_device.id != @device.id -> - link.source_device - - link.target_device -> - link.target_device - - true -> - nil - end %> - - - - - - - - - <% end %> - -
DeviceIP AddressInterfaceLink TypeConfidenceStatus
- <%= if peer_device do %> - <.link - navigate={~p"/devices/#{peer_device.id}"} - class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" - > - {peer_device.name} - - <% else %> -
- {link.discovered_remote_name || link.discovered_remote_mac || - "Unknown"} -
- <% end %> -
- <%= if peer_device do %> - {peer_device.ip_address} - <% else %> - {link.discovered_remote_ip || "-"} - <% end %> - - <%= if link.source_interface do %> - {link.source_interface.if_name} - <% else %> - - - <% end %> - - - "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" - - link.link_type == "mac_match" -> - "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" - - link.link_type == "arp_inference" -> - "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" - - true -> - "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200" - end - ]}> - {link.link_type} - - -
-
-
= 0.9 -> "bg-green-500" - link.confidence >= 0.7 -> "bg-blue-500" - link.confidence >= 0.5 -> "bg-yellow-500" - true -> "bg-red-500" - end - ]} - style={"width: #{trunc(link.confidence * 100)}%"} - > -
-
- - {trunc(link.confidence * 100)}% - -
-
- <%= if peer_device do %> - - {t("Managed")} - - <% else %> - - {t("Discovered")} - - <% end %> -
-
-
- <% end %> -
- """ - end - - # Helper functions - delegate to Formatters or implement locally - defp format_date(date), do: Formatters.format_date(date) - defp format_sensor_value(value, divisor), do: Formatters.format_sensor_value(value, divisor) - defp format_location(location), do: Formatters.format_location(location) - defp format_uptime(timeticks), do: Formatters.format_uptime(timeticks) - defp format_device_age(datetime), do: Formatters.format_device_age(datetime) - defp format_bytes(bytes), do: Formatters.format_bytes(bytes) - - defp config_change_dot_color(event) do - cond do - event.change_size > 50 -> "bg-red-500" - event.change_size > 20 -> "bg-yellow-500" - true -> "bg-green-500" - end - end - - defp firmware_update_available?(nil, _), do: false - defp firmware_update_available?(_, nil), do: false - - defp firmware_update_available?(snmp_device, available_firmware) do - current = snmp_device.firmware_version - available = available_firmware.version - - # Extract clean version numbers for comparison - current_clean = extract_version_number(current) - available_clean = extract_version_number(available) - - current_clean && available_clean && - VersionComparator.newer(current_clean, available_clean) - end - - defp extract_version_number(nil), do: nil - defp extract_version_number(""), do: nil - - defp extract_version_number(version_string) when is_binary(version_string) do - # Match semantic version pattern: X.Y or X.Y.Z - case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do - [version] -> version - _ -> nil - end - end -end diff --git a/lib/towerops_web/live/device_live/components/ports_tab.ex b/lib/towerops_web/live/device_live/components/ports_tab.ex deleted file mode 100644 index 78ce6e3b..00000000 --- a/lib/towerops_web/live/device_live/components/ports_tab.ex +++ /dev/null @@ -1,331 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.PortsTab do - @moduledoc """ - LiveComponent for the device ports (interfaces) tab. - - Displays network interfaces grouped by type (Ethernet, VLAN, Wireless, etc.) - with traffic statistics and utilization metrics. - """ - use ToweropsWeb, :live_component - - alias ToweropsWeb.DeviceLive.Helpers.Formatters - - # SNMP interface type to category mappings - @interface_type_categories %{ - 6 => "Ethernet", - 24 => "Loopback", - 23 => "PPP", - 108 => "PPPoE", - 131 => "Tunnel", - 135 => "VLAN", - 136 => "VLAN", - 161 => "IEEE 802.11", - 244 => "WWP" - } - - # Display order for interface categories - @interface_category_order %{ - "Ethernet" => 1, - "VLAN" => 2, - "IEEE 802.11" => 3, - "PPPoE" => 4, - "PPP" => 5, - "Tunnel" => 6, - "Loopback" => 7, - "WWP" => 8, - "Other" => 99 - } - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_ports_data()} - end - - defp load_ports_data(socket) do - interfaces = socket.assigns.snmp_interfaces - - interfaces_with_utilization = - Enum.map(interfaces, fn interface -> - utilization = - if interface.configured_capacity_bps do - Towerops.Capacity.get_utilization(interface) - end - - Map.put(interface, :utilization, utilization) - end) - - interfaces_by_type = group_interfaces_by_type(interfaces_with_utilization) - - assign(socket, :interfaces_by_type, interfaces_by_type) - end - - # Group interfaces by type for organized display - defp group_interfaces_by_type(interfaces) do - interfaces - |> Enum.group_by(&get_interface_type_category(&1.if_type)) - |> Enum.map(fn {category, interfaces} -> - {category, Enum.sort_by(interfaces, & &1.if_index)} - end) - |> Enum.sort_by(fn {category, _} -> interface_type_order(category) end) - end - - # Map SNMP interface types to human-readable categories - defp get_interface_type_category(if_type) when is_integer(if_type) do - Map.get(@interface_type_categories, if_type, "Other") - end - - defp get_interface_type_category(_), do: "Other" - - # Define display order for interface categories - defp interface_type_order(category) do - Map.get(@interface_category_order, category, 99) - end - - @impl true - def render(assigns) do - ~H""" -
- <%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %> -
- <%= for {category, interfaces} <- @interfaces_by_type do %> -
-
-

- {category} Interfaces - - ({length(interfaces)}) - -

-
-
- - - - - - - - - - - - - - - - - - <%= for interface <- interfaces do %> - - - - - - - - - - - - - - <% end %> - -
#NameStatusSpeedIP AddressMACCapacityUtilizationInOutErrors
- {interface.if_index} - - <.link - navigate={ - ~p"/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}" - } - class="hover:text-blue-600 dark:hover:text-blue-400" - > -
- {interface.if_name || interface.if_descr} -
- <%= if interface.if_alias do %> -
- {interface.if_alias} -
- <% end %> - -
- - - - {String.upcase(interface.if_oper_status || "unknown")} - - - - {format_speed(interface.if_speed)} - - <% interface_ips = - Map.get(@ip_addresses_by_interface, interface.id, []) %> - <%= if Enum.empty?(interface_ips) do %> - - - <% else %> -
- <%= for ip <- interface_ips do %> -
- {ip.ip_address} - <%= if ip.prefix_length do %> - /{ip.prefix_length} - <% end %> - <.icon - name="hero-clipboard-document" - class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/iip:opacity-100 transition-opacity text-gray-400" - /> -
- <% end %> -
- <% end %> -
- - {interface.if_phys_address} - <.icon - name="hero-clipboard-document" - class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" - /> - - - - - <%= if interface.configured_capacity_bps do %> -
- - {format_speed(interface.configured_capacity_bps)} - - - {capacity_source_label(interface.capacity_source)} - - -
- <% else %> - - <% end %> -
- <%= if interface.utilization do %> -
-
-
-
-
- - {Float.round(interface.utilization.utilization_pct, 1)}% - -
- <% else %> - - - <% end %> -
- {if interface.latest_stat && interface.latest_stat.if_in_octets, - do: format_bytes(interface.latest_stat.if_in_octets), - else: "-"} - - {if interface.latest_stat && interface.latest_stat.if_out_octets, - do: format_bytes(interface.latest_stat.if_out_octets), - else: "-"} - - <% total_errors = - interface.latest_stat && - (interface.latest_stat.if_in_errors || 0) + - (interface.latest_stat.if_out_errors || 0) + - (interface.latest_stat.if_in_discards || 0) + - (interface.latest_stat.if_out_discards || 0) %> - <%= if total_errors && total_errors > 0 do %> - <.link - navigate={ - ~p"/devices/#{@device.id}/graph/interface_errors?interface_id=#{interface.id}" - } - class="text-red-600 dark:text-red-400 hover:underline" - > - {total_errors} - - <% else %> - - - <% end %> -
-
-
- <% end %> -
- <% else %> -
-

No interfaces discovered

-
- <% end %> -
- """ - end - - # Helper functions - delegate to Formatters module - defp format_speed(speed_bps), do: Formatters.format_speed(speed_bps) - defp format_bytes(bytes), do: Formatters.format_bytes(bytes) - defp capacity_source_label(source), do: Formatters.capacity_source_label(source) - defp capacity_source_class(source), do: Formatters.capacity_source_class(source) - defp utilization_color(pct), do: Formatters.utilization_color(pct) - - defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" - defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" - defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" -end diff --git a/lib/towerops_web/live/device_live/components/preseem_tab.ex b/lib/towerops_web/live/device_live/components/preseem_tab.ex deleted file mode 100644 index d64a0ae6..00000000 --- a/lib/towerops_web/live/device_live/components/preseem_tab.ex +++ /dev/null @@ -1,291 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.PreseemTab do - @moduledoc """ - LiveComponent for the device Preseem integration tab. - - Displays Preseem access point data, subscriber metrics, and device insights. - """ - use ToweropsWeb, :live_component - - alias ToweropsWeb.DeviceLive.Helpers.DataLoaders - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_preseem_data()} - end - - defp load_preseem_data(socket) do - device = socket.assigns.device - preseem_data = DataLoaders.load_preseem_data(device) - - socket - |> assign(:preseem_access_point, preseem_data.preseem_access_point) - |> assign(:preseem_metrics, preseem_data.preseem_metrics) - |> assign(:preseem_insights, preseem_data.preseem_insights) - end - - @impl true - def render(assigns) do - ~H""" -
- <%= if @preseem_access_point do %> -
- <%!-- Insights section --%> - <%= if @preseem_insights != [] do %> -
- <%= for insight <- @preseem_insights do %> -
- "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" - - "warning" -> - "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800" - - _ -> - "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800" - end - ]}> -
-
"text-red-500" - "warning" -> "text-yellow-500" - _ -> "text-blue-500" - end - ]}> - <%= case insight.urgency do %> - <% "critical" -> %> - <.icon name="hero-exclamation-triangle" class="h-5 w-5" /> - <% "warning" -> %> - <.icon name="hero-exclamation-circle" class="h-5 w-5" /> - <% _ -> %> - <.icon name="hero-information-circle" class="h-5 w-5" /> - <% end %> -
-
-

- {insight.title} -

-

- {insight.description} -

-
-
- -
- <% end %> -
- <% end %> - - <%!-- Score cards --%> -
-
-
QoE Score
-
- {if @preseem_access_point.qoe_score, - do: Float.round(@preseem_access_point.qoe_score, 1), - else: "---"} -
-
-
-
- {t("Capacity Score")} -
-
- {if @preseem_access_point.capacity_score, - do: Float.round(@preseem_access_point.capacity_score, 1), - else: "---"} -
-
-
-
RF Score
-
- {if @preseem_access_point.rf_score, - do: Float.round(@preseem_access_point.rf_score, 1), - else: "---"} -
-
-
- - <%!-- Details card --%> -
-
-

- {t("Access Point Details")} -

-
-
-
-
-
Name
-
- {@preseem_access_point.name || "---"} -
-
-
-
- {t("Preseem ID")} -
-
- {@preseem_access_point.preseem_id} -
-
-
-
- {t("Subscribers")} -
-
- {@preseem_access_point.subscriber_count || "---"} -
-
-
-
- {t("Busy Hours")} -
-
- <%= if @preseem_access_point.busy_hours do %> - {@preseem_access_point.busy_hours} hrs/day - <% else %> - --- - <% end %> -
-
-
-
- {t("Airtime Utilization")} -
-
- <%= if @preseem_access_point.airtime_utilization do %> - {Float.round(@preseem_access_point.airtime_utilization, 1)}% - <% else %> - --- - <% end %> -
-
-
-
- {t("Match Type")} -
-
- {@preseem_access_point.match_confidence} -
-
-
-
-
- - <%!-- Recent metrics table --%> - <%= if @preseem_metrics != [] do %> -
-
-

- {t("Recent QoE Metrics")} -

-
-
- - - - - - - - - - - - - - <%= for metric <- @preseem_metrics do %> - - - - - - - - - - <% end %> - -
- {t("Time")} - - {t("Latency (ms)")} - - {t("P95 Latency")} - - {t("Jitter (ms)")} - - {t("Loss (%)")} - - {t("Throughput")} - - {t("Subscribers")} -
- {ToweropsWeb.TimeHelpers.format_iso8601( - metric.recorded_at, - @current_scope.timezone, - @current_scope.time_format - )} - - {if metric.avg_latency, - do: Float.round(metric.avg_latency, 1), - else: "---"} - - {if metric.p95_latency, - do: Float.round(metric.p95_latency, 1), - else: "---"} - - {if metric.avg_jitter, - do: Float.round(metric.avg_jitter, 1), - else: "---"} - - {if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"} - - {if metric.avg_throughput, - do: "#{Float.round(metric.avg_throughput, 1)} Mbps", - else: "---"} - - {metric.subscriber_count || "---"} -
-
-
- <% end %> -
- <% else %> -
-

- {t("No Preseem data linked to this device.")} -

-
- <% end %> -
- """ - end - - @impl true - def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do - case Towerops.Preseem.dismiss_insight(insight_id) do - {:ok, _} -> - # Reload preseem data after dismissing insight - {:noreply, load_preseem_data(socket)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to dismiss insight")} - end - end -end diff --git a/lib/towerops_web/live/device_live/components/wireless_tab.ex b/lib/towerops_web/live/device_live/components/wireless_tab.ex deleted file mode 100644 index 9c4a2b03..00000000 --- a/lib/towerops_web/live/device_live/components/wireless_tab.ex +++ /dev/null @@ -1,195 +0,0 @@ -defmodule ToweropsWeb.DeviceLive.Components.WirelessTab do - @moduledoc """ - LiveComponent for the device wireless clients tab. - - Displays wireless clients connected to the device with optional - subscriber matching from Gaiia integration. - """ - use ToweropsWeb, :live_component - - alias ToweropsWeb.DeviceLive.Helpers.DataLoaders - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> load_wireless_data()} - end - - defp load_wireless_data(socket) do - device_id = socket.assigns.device.id - wireless_data = DataLoaders.load_wireless_data(device_id) - - socket - |> assign(:wireless_clients, wireless_data.wireless_clients) - |> assign(:wireless_client_subscribers, wireless_data.wireless_client_subscribers) - |> assign(:wireless_client_count, wireless_data.wireless_client_count) - |> assign(:wireless_matched_count, wireless_data.wireless_matched_count) - |> assign(:wireless_last_updated, wireless_data.wireless_last_updated) - |> assign(:wireless_clients_available, wireless_data.wireless_clients_available) - end - - @impl true - def render(assigns) do - ~H""" -
- <%= if @wireless_clients && length(@wireless_clients) > 0 do %> -
-
-
-

- {t("Connected Wireless Clients")} -

-

- {length(@wireless_clients)} clients — {@wireless_matched_count} subscribers matched - <%= if @wireless_last_updated do %> - · Last updated {format_relative_time(@wireless_last_updated)} - <% end %> -

-
-
-
- - - - - - - - - - - - - - - - <%= for client <- @wireless_clients do %> - <% subscriber_link = - Map.get(@wireless_client_subscribers, String.downcase(client.mac_address)) %> - - - - - - - - - - - - <% end %> - -
MAC AddressIP AddressSubscriberSignalSNRDistanceTX RateRX RateUptime
- - {client.mac_address} - <.icon - name="hero-clipboard-document" - class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" - /> - - - <%= if client.ip_address do %> - - {client.ip_address} - <.icon - name="hero-clipboard-document" - class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400" - /> - - <% else %> - - <% end %> - - <%= if subscriber_link && subscriber_link.gaiia_account do %> - - {subscriber_link.gaiia_account.account_name} - - <% else %> - - <% end %> - - <%= if client.signal_strength do %> - <.signal_badge value={client.signal_strength} /> - <% else %> - - <% end %> - - <%= if client.snr do %> - <.snr_badge value={client.snr} /> - <% else %> - - <% end %> - - <%= if client.distance do %> - {client.distance}m - <% else %> - - <% end %> - - {format_rate(client.tx_rate)} - - {format_rate(client.rx_rate)} - - <%= if client.uptime_seconds do %> - {format_uptime(client.uptime_seconds * 100)} - <% else %> - - <% end %> -
-
-
- <% else %> -
- <.icon - name="hero-signal" - class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600" - /> -

- {t("No wireless clients")} -

-

- {t("No clients are currently connected to this access point.")} -

-
- <% end %> -
- """ - end - - # Helper functions - defp format_relative_time(datetime) do - ToweropsWeb.TimeHelpers.format_time_ago(datetime) - end - - defp format_rate(nil), do: "—" - - defp format_rate(kbps) when is_integer(kbps) do - mbps = kbps / 1000 - "#{Float.round(mbps, 1)} Mbps" - end - - defp format_uptime(timeticks) when is_integer(timeticks) do - seconds = div(timeticks, 100) - hours = div(seconds, 3600) - remainder = rem(seconds, 3600) - minutes = div(remainder, 60) - - cond do - hours > 0 -> "#{hours}h #{minutes}m" - minutes > 0 -> "#{minutes}m" - true -> "<1m" - end - end - - defp format_uptime(_), do: "—" -end diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index c2639d9b..fa698ab8 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -617,13 +617,15 @@ defmodule ToweropsWeb.DeviceLive.Form do defp handle_agent_assignment(_device_id, _), do: :ok # Sanitize device params - trim whitespace from IP address and handle device role source - defp sanitize_device_params(params) do + @doc false + def sanitize_device_params(params) do params |> sanitize_ip_address() |> sanitize_device_role() end - defp sanitize_ip_address(params) do + @doc false + def sanitize_ip_address(params) do case Map.get(params, "ip_address") do ip when is_binary(ip) -> Map.put(params, "ip_address", String.trim(ip)) @@ -633,14 +635,13 @@ defmodule ToweropsWeb.DeviceLive.Form do end end - # Device role sanitization (auto-inference disabled, all roles are manual) - defp sanitize_device_role(params) do + @doc false + def sanitize_device_role(params) do case Map.get(params, "device_role") do role when is_binary(role) and role != "" -> params _ -> - # Empty string or nil - just pass through (schema default will apply) params end end @@ -692,20 +693,23 @@ defmodule ToweropsWeb.DeviceLive.Form do end end - defp get_device_agent(%{"agent_token_id" => id}) when is_binary(id) and id != "", do: id - defp get_device_agent(_), do: nil + @doc false + def get_device_agent(%{"agent_token_id" => id}) when is_binary(id) and id != "", do: id + def get_device_agent(_), do: nil - defp get_site_agent(%{"site_id" => site_id}, %{available_sites: sites}) when is_binary(site_id) do + @doc false + def get_site_agent(%{"site_id" => site_id}, %{available_sites: sites}) when is_binary(site_id) do case Enum.find(sites, &(&1.id == site_id)) do %{agent_token_id: id} when not is_nil(id) -> id _ -> nil end end - defp get_site_agent(_, _), do: nil + def get_site_agent(_, _), do: nil - defp get_org_agent(%{organization: %{default_agent_token_id: id}}) when not is_nil(id), do: id - defp get_org_agent(_), do: nil + @doc false + def get_org_agent(%{organization: %{default_agent_token_id: id}}) when not is_nil(id), do: id + def get_org_agent(_), do: nil defp send_credential_test_to_agent(agent_token_id, device_map, test_id) do require Logger @@ -742,11 +746,13 @@ defmodule ToweropsWeb.DeviceLive.Form do } end - defp get_value(map, key, default), do: map[key] || default + @doc false + def get_value(map, key, default), do: map[key] || default - defp normalize_port(port) when is_binary(port), do: String.to_integer(port) - defp normalize_port(port) when is_integer(port), do: port - defp normalize_port(_), do: 161 + @doc false + def normalize_port(port) when is_binary(port), do: String.to_integer(port) + def normalize_port(port) when is_integer(port), do: port + def normalize_port(_), do: 161 # Check if a non-routable IP is being used with cloud poller # Skip this check in dev mode or for specific users diff --git a/lib/towerops_web/live/device_live/helpers/chart_builders.ex b/lib/towerops_web/live/device_live/helpers/chart_builders.ex index f7b09c26..1c429426 100644 --- a/lib/towerops_web/live/device_live/helpers/chart_builders.ex +++ b/lib/towerops_web/live/device_live/helpers/chart_builders.ex @@ -53,7 +53,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do } end - defp processor_reading_to_data_point(reading) do + @doc false + def processor_reading_to_data_point(reading) do %{ x: DateTime.to_unix(reading.checked_at, :millisecond), y: if(reading.load_percent, do: Float.round(reading.load_percent, 1)) @@ -97,7 +98,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do } end - defp storage_reading_to_data_point(reading) do + @doc false + def storage_reading_to_data_point(reading) do %{ x: DateTime.to_unix(reading.checked_at, :millisecond), y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1)) @@ -144,7 +146,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do } end - defp reading_to_data_point(reading) do + @doc false + def reading_to_data_point(reading) do %{ x: DateTime.to_unix(reading.checked_at, :millisecond), y: if(reading.value, do: Float.round(reading.value, 1)) @@ -177,7 +180,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do end end - defp latency_check_to_data_point(check) do + @doc false + def latency_check_to_data_point(check) do %{ x: DateTime.to_unix(check.checked_at, :millisecond), y: check.response_time_ms @@ -232,7 +236,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do end # Calculate BPS for a single interface's stats - defp calculate_interface_bps(stats) do + @doc false + def calculate_interface_bps(stats) do stats |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [s1, s2] -> @@ -385,9 +390,11 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do end # Convert capacity sensor value (in Mbps) to bps - defp capacity_value_to_bps(nil), do: 0 - defp capacity_value_to_bps(value) when is_number(value), do: round(value * 1_000_000) - defp capacity_value_to_bps(_), do: 0 + @doc false + @spec capacity_value_to_bps(term()) :: non_neg_integer() + def capacity_value_to_bps(nil), do: 0 + def capacity_value_to_bps(value) when is_number(value), do: round(value * 1_000_000) + def capacity_value_to_bps(_), do: 0 # Build capacity data points from sensor readings defp build_capacity_data_from_sensors(rx_readings, tx_readings, rx_sensor, tx_sensor) do diff --git a/lib/towerops_web/live/device_live/helpers/data_loaders.ex b/lib/towerops_web/live/device_live/helpers/data_loaders.ex index cbc28840..e644581a 100644 --- a/lib/towerops_web/live/device_live/helpers/data_loaders.ex +++ b/lib/towerops_web/live/device_live/helpers/data_loaders.ex @@ -58,11 +58,17 @@ defmodule ToweropsWeb.DeviceLive.Helpers.DataLoaders do } end - # Agent is considered offline if it hasn't been seen in the last 5 minutes - defp agent_offline?(%{is_cloud_poller: true}), do: false - defp agent_offline?(%{last_seen_at: nil}), do: true + @doc """ + True if an agent should be considered offline. - defp agent_offline?(%{last_seen_at: last_seen_at}) do + Cloud pollers are never offline. Agents without a `last_seen_at` are always + offline. Otherwise the cutoff is 5 minutes from `DateTime.utc_now/0`. + """ + @spec agent_offline?(map()) :: boolean() + def agent_offline?(%{is_cloud_poller: true}), do: false + def agent_offline?(%{last_seen_at: nil}), do: true + + def agent_offline?(%{last_seen_at: last_seen_at}) do five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute) DateTime.before?(last_seen_at, five_minutes_ago) end @@ -176,28 +182,36 @@ defmodule ToweropsWeb.DeviceLive.Helpers.DataLoaders do end end - defp determine_vendor(manufacturer) when is_binary(manufacturer) do - manufacturer_lower = String.downcase(manufacturer) + @doc """ + Determines a canonical vendor string from a manufacturer string. + Recognizes MikroTik, Cisco, and Ubiquiti. Returns `nil` for others. + """ + @spec determine_vendor(String.t() | nil | term()) :: String.t() | nil + def determine_vendor(manufacturer) when is_binary(manufacturer) do + manufacturer |> String.downcase() |> match_vendor() + end + def determine_vendor(_), do: nil + + defp match_vendor(lower) do cond do - String.contains?(manufacturer_lower, "mikrotik") -> "mikrotik" - String.contains?(manufacturer_lower, "cisco") -> "cisco" - String.contains?(manufacturer_lower, "ubiquiti") -> "ubiquiti" + String.contains?(lower, "mikrotik") -> "mikrotik" + String.contains?(lower, "cisco") -> "cisco" + String.contains?(lower, "ubiquiti") -> "ubiquiti" true -> nil end end - defp determine_vendor(_), do: nil - - defp determine_product_line(manufacturer) when is_binary(manufacturer) do - manufacturer_lower = String.downcase(manufacturer) - - if String.contains?(manufacturer_lower, "mikrotik") do - "routeros" - end + @doc """ + Determines a product-line slug from a manufacturer string. + Only MikroTik is recognized (→ `"routeros"`); other vendors return `nil`. + """ + @spec determine_product_line(String.t() | nil | term()) :: String.t() | nil + def determine_product_line(manufacturer) when is_binary(manufacturer) do + if manufacturer |> String.downcase() |> String.contains?("mikrotik"), do: "routeros" end - defp determine_product_line(_), do: nil + def determine_product_line(_), do: nil @doc """ Loads neighbors (LLDP/CDP) with equipment associations. diff --git a/lib/towerops_web/live/device_live/helpers/formatters.ex b/lib/towerops_web/live/device_live/helpers/formatters.ex index a8c29489..a914793e 100644 --- a/lib/towerops_web/live/device_live/helpers/formatters.ex +++ b/lib/towerops_web/live/device_live/helpers/formatters.ex @@ -29,22 +29,13 @@ defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do - nil -> "-" """ @spec format_speed(integer() | nil) :: String.t() - def format_speed(speed_bps) when is_integer(speed_bps) do - cond do - speed_bps >= 1_000_000_000 -> - "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" + def format_speed(bps) when is_integer(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps" - speed_bps >= 1_000_000 -> - "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" + def format_speed(bps) when is_integer(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps" - speed_bps >= 1_000 -> - "#{Float.round(speed_bps / 1_000, 1)} Kbps" - - true -> - "#{speed_bps} bps" - end - end + def format_speed(bps) when is_integer(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps" + def format_speed(bps) when is_integer(bps), do: "#{bps} bps" def format_speed(_), do: "-" @doc """ @@ -60,25 +51,17 @@ defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do def format_bytes(nil), do: "-" def format_bytes(0), do: "0 B" - def format_bytes(bytes) when is_integer(bytes) do - cond do - bytes >= 1_099_511_627_776 -> - "#{Float.round(bytes / 1_099_511_627_776, 1)} TB" + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_099_511_627_776, + do: "#{Float.round(bytes / 1_099_511_627_776, 1)} TB" - bytes >= 1_073_741_824 -> - "#{Float.round(bytes / 1_073_741_824, 1)} GB" + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_073_741_824, + do: "#{Float.round(bytes / 1_073_741_824, 1)} GB" - bytes >= 1_048_576 -> - "#{Float.round(bytes / 1_048_576, 1)} MB" + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB" - bytes >= 1024 -> - "#{Float.round(bytes / 1024, 1)} KB" - - true -> - "#{bytes} B" - end - end + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1024, do: "#{Float.round(bytes / 1024, 1)} KB" + def format_bytes(bytes) when is_integer(bytes), do: "#{bytes} B" def format_bytes(_), do: "-" @doc """ @@ -147,16 +130,14 @@ defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do def time_ago(nil), do: "Never" def time_ago(datetime) do - diff = DateTime.diff(DateTime.utc_now(), datetime, :second) - - cond do - diff < 60 -> "#{diff}s ago" - diff < 3600 -> "#{div(diff, 60)}m ago" - diff < 86_400 -> "#{div(diff, 3600)}h ago" - true -> "#{div(diff, 86_400)}d ago" - end + DateTime.utc_now() |> DateTime.diff(datetime, :second) |> format_ago() end + defp format_ago(diff) when diff < 60, do: "#{diff}s ago" + defp format_ago(diff) when diff < 3600, do: "#{div(diff, 60)}m ago" + defp format_ago(diff) when diff < 86_400, do: "#{div(diff, 3600)}h ago" + defp format_ago(diff), do: "#{div(diff, 86_400)}d ago" + @doc """ Formats SNMP uptime (in timeticks) to human-readable duration. diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index 2c39a89d..3b68010d 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -382,10 +382,11 @@ defmodule ToweropsWeb.DeviceLive.Index do |> filter_by_status(status_filter) end - defp filter_by_search(devices, ""), do: devices - defp filter_by_search(devices, nil), do: devices + @doc false + def filter_by_search(devices, ""), do: devices + def filter_by_search(devices, nil), do: devices - defp filter_by_search(devices, query) do + def filter_by_search(devices, query) when is_binary(query) do q = String.downcase(query) Enum.filter(devices, fn device -> @@ -394,11 +395,12 @@ defmodule ToweropsWeb.DeviceLive.Index do end) end - defp filter_by_status(devices, "all"), do: devices - defp filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up)) - defp filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down)) - defp filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown)) - defp filter_by_status(devices, _), do: devices + @doc false + def filter_by_status(devices, "all"), do: devices + def filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up)) + def filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down)) + def filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown)) + def filter_by_status(devices, _), do: devices defp enqueue_discovery(device_id) do if Application.get_env(:towerops, :env) == :test do @@ -470,7 +472,8 @@ defmodule ToweropsWeb.DeviceLive.Index do ) end - defp calculate_site_stats(devices) do + @doc false + def calculate_site_stats(devices) do status_counts = Enum.frequencies_by(devices, & &1.status) %{ @@ -481,10 +484,11 @@ defmodule ToweropsWeb.DeviceLive.Index do } end - defp device_type_label(nil), do: "Unknown" - defp device_type_label("access_point"), do: "Access Point" - defp device_type_label(role) when is_binary(role), do: String.capitalize(role) - defp device_type_label(_), do: "Unknown" + @doc false + def device_type_label(nil), do: "Unknown" + def device_type_label("access_point"), do: "Access Point" + def device_type_label(role) when is_binary(role), do: String.capitalize(role) + def device_type_label(_), do: "Unknown" defp load_site_subscribers(devices) do site_ids = diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index d66b40db..ea27363b 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -662,24 +662,26 @@ defmodule ToweropsWeb.DeviceLive.Show do defp utilization_color(pct), do: Formatters.utilization_color(pct) # Additional template-used formatting functions (not yet in Formatters module) - defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" - defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" - defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" + @doc false + def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" + def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" + def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" - defp format_event_type(event_type) do + @doc false + def format_event_type(event_type) do event_type |> String.replace("_", " ") |> String.capitalize() end - defp firmware_update_available?(nil, _), do: false - defp firmware_update_available?(_, nil), do: false + @doc false + def firmware_update_available?(nil, _), do: false + def firmware_update_available?(_, nil), do: false - defp firmware_update_available?(snmp_device, available_firmware) do + def firmware_update_available?(snmp_device, available_firmware) do current = snmp_device.firmware_version available = available_firmware.version - # Extract clean version numbers for comparison current_clean = extract_version_number(current) available_clean = extract_version_number(available) @@ -687,17 +689,19 @@ defmodule ToweropsWeb.DeviceLive.Show do VersionComparator.newer(current_clean, available_clean) end - defp extract_version_number(nil), do: nil - defp extract_version_number(""), do: nil + @doc false + def extract_version_number(nil), do: nil + def extract_version_number(""), do: nil - defp extract_version_number(version_string) when is_binary(version_string) do - # Match semantic version pattern: X.Y or X.Y.Z + def extract_version_number(version_string) when is_binary(version_string) do case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do [version] -> version _ -> nil end end + def extract_version_number(_), do: nil + # Group interfaces by type for organized display defp group_interfaces_by_type(interfaces) do interfaces @@ -1062,16 +1066,17 @@ defmodule ToweropsWeb.DeviceLive.Show do # Checks tab helpers - defp group_title(:snmp_sensors), do: "SNMP Sensors" - defp group_title(:snmp_interfaces), do: "SNMP Interfaces" - defp group_title(:snmp_processors), do: "SNMP Processors" - defp group_title(:snmp_storage), do: "SNMP Storage" - defp group_title(:http_checks), do: "HTTP Checks" - defp group_title(:tcp_checks), do: "TCP Checks" - defp group_title(:dns_checks), do: "DNS Checks" - defp group_title(:ssl_checks), do: "SSL Certificate Checks" - defp group_title(:ping_checks), do: "Ping Checks" - defp group_title(:other_checks), do: "Other Checks" + @doc false + def group_title(:snmp_sensors), do: "SNMP Sensors" + def group_title(:snmp_interfaces), do: "SNMP Interfaces" + def group_title(:snmp_processors), do: "SNMP Processors" + def group_title(:snmp_storage), do: "SNMP Storage" + def group_title(:http_checks), do: "HTTP Checks" + def group_title(:tcp_checks), do: "TCP Checks" + def group_title(:dns_checks), do: "DNS Checks" + def group_title(:ssl_checks), do: "SSL Certificate Checks" + def group_title(:ping_checks), do: "Ping Checks" + def group_title(:other_checks), do: "Other Checks" defp render_status_badge(0) do assigns = %{} @@ -1131,19 +1136,20 @@ defmodule ToweropsWeb.DeviceLive.Show do end end - defp format_check_value(%{value: nil}, _check), do: "-" + @doc false + def format_check_value(%{value: nil}, _check), do: "-" - defp format_check_value(%{value: value}, check) when is_number(value) do + def format_check_value(%{value: value}, check) when is_number(value) do case check.check_type do "snmp_sensor" -> format_check_sensor_value(value, check.config) - "snmp_processor" -> "#{Float.round(value, 1)}%" - "snmp_storage" -> "#{Float.round(value, 1)}%" - t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms" - _ -> value |> Float.round(2) |> to_string() + "snmp_processor" -> "#{Float.round(value / 1, 1)}%" + "snmp_storage" -> "#{Float.round(value / 1, 1)}%" + t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value / 1, 2)} ms" + _ -> value |> Kernel./(1) |> Float.round(2) |> to_string() end end - defp format_check_value(_result, _check), do: "-" + def format_check_value(_result, _check), do: "-" @sensor_value_formats %{ "temperature" => {1, "°C"}, diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index a53baec1..0cccf886 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -193,26 +193,27 @@ defmodule ToweropsWeb.GraphLive.Show do {chart_data, unit, auto_scale, false} end - defp get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()} - defp get_time_range_for_graph("6h"), do: {DateTime.add(DateTime.utc_now(), -6, :hour), DateTime.utc_now()} - defp get_time_range_for_graph("12h"), do: {DateTime.add(DateTime.utc_now(), -12, :hour), DateTime.utc_now()} - defp get_time_range_for_graph("24h"), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()} - defp get_time_range_for_graph("7d"), do: {DateTime.add(DateTime.utc_now(), -7, :day), DateTime.utc_now()} - defp get_time_range_for_graph("30d"), do: {DateTime.add(DateTime.utc_now(), -30, :day), DateTime.utc_now()} - defp get_time_range_for_graph(_), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()} + @doc false + def get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()} + def get_time_range_for_graph("6h"), do: {DateTime.add(DateTime.utc_now(), -6, :hour), DateTime.utc_now()} + def get_time_range_for_graph("12h"), do: {DateTime.add(DateTime.utc_now(), -12, :hour), DateTime.utc_now()} + def get_time_range_for_graph("24h"), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()} + def get_time_range_for_graph("7d"), do: {DateTime.add(DateTime.utc_now(), -7, :day), DateTime.utc_now()} + def get_time_range_for_graph("30d"), do: {DateTime.add(DateTime.utc_now(), -30, :day), DateTime.utc_now()} + def get_time_range_for_graph(_), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()} - defp get_check_unit_and_scale(%{check_type: "snmp_sensor", config: config}) do - # Get unit from sensor config + @doc false + def get_check_unit_and_scale(%{check_type: "snmp_sensor", config: config}) do unit = config["sensor_unit"] || "" {unit, true} end - defp get_check_unit_and_scale(%{check_type: "snmp_processor"}), do: {"%", false} - defp get_check_unit_and_scale(%{check_type: "snmp_storage"}), do: {"%", false} - defp get_check_unit_and_scale(%{check_type: "http"}), do: {"ms", true} - defp get_check_unit_and_scale(%{check_type: "tcp"}), do: {"ms", true} - defp get_check_unit_and_scale(%{check_type: "dns"}), do: {"ms", true} - defp get_check_unit_and_scale(_), do: {"", true} + def get_check_unit_and_scale(%{check_type: "snmp_processor"}), do: {"%", false} + def get_check_unit_and_scale(%{check_type: "snmp_storage"}), do: {"%", false} + def get_check_unit_and_scale(%{check_type: "http"}), do: {"ms", true} + def get_check_unit_and_scale(%{check_type: "tcp"}), do: {"ms", true} + def get_check_unit_and_scale(%{check_type: "dns"}), do: {"ms", true} + def get_check_unit_and_scale(_), do: {"", true} defp initialize_graph_view(socket, device_id, sensor_type, params) do maybe_subscribe_to_device(socket, device_id) @@ -933,8 +934,8 @@ defmodule ToweropsWeb.GraphLive.Show do |> Enum.reject(&is_nil/1) end - # Format value with unit for display - defp format_value(value, "bps") do + @doc false + def format_value(value, "bps") do abs_value = abs(value) cond do @@ -952,17 +953,15 @@ defmodule ToweropsWeb.GraphLive.Show do end end - # For counts (empty unit), format as integer - defp format_value(value, "") do + def format_value(value, "") do "#{round(value)}" end - # For other units, format with one decimal place - defp format_value(value, unit) when is_float(value) do + def format_value(value, unit) when is_float(value) do "#{Float.round(value, 1)} #{unit}" end - defp format_value(value, unit) when is_integer(value) do + def format_value(value, unit) when is_integer(value) do "#{Float.round(value / 1, 1)} #{unit}" end diff --git a/lib/towerops_web/live/insights_live/index.ex b/lib/towerops_web/live/insights_live/index.ex index c06db850..a21b876b 100644 --- a/lib/towerops_web/live/insights_live/index.ex +++ b/lib/towerops_web/live/insights_live/index.ex @@ -131,53 +131,30 @@ defmodule ToweropsWeb.InsightsLive.Index do assign(socket, :insights, insights) end - defp urgency_classes(urgency) do - case urgency do - "critical" -> - "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + @doc false + def urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + def urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + def urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" + def urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" - "warning" -> - "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + @doc false + def source_classes("preseem"), do: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400" + def source_classes("gaiia"), do: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400" + def source_classes("snmp"), do: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400" + def source_classes("system"), do: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" + def source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" - "info" -> - "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" - - _ -> - "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" - end - end - - defp source_classes(source) do - case source do - "preseem" -> - "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400" - - "gaiia" -> - "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400" - - "snmp" -> - "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400" - - "system" -> - "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" - - _ -> - "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" - end - end - - defp build_filter_params(base, overrides) do + @doc false + def build_filter_params(base, overrides) do base |> Map.merge(overrides) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end - defp selected?(selected_ids, id) do - MapSet.member?(selected_ids, id) - end + @doc false + def selected?(selected_ids, id), do: MapSet.member?(selected_ids, id) - defp any_selected?(selected_ids) do - MapSet.size(selected_ids) > 0 - end + @doc false + def any_selected?(selected_ids), do: MapSet.size(selected_ids) > 0 end diff --git a/lib/towerops_web/live/maintenance_live/form.ex b/lib/towerops_web/live/maintenance_live/form.ex index 71a03ac1..d1b74fdd 100644 --- a/lib/towerops_web/live/maintenance_live/form.ex +++ b/lib/towerops_web/live/maintenance_live/form.ex @@ -82,13 +82,15 @@ defmodule ToweropsWeb.MaintenanceLive.Form do end end - defp maybe_clear_scope(params, "org"), do: Map.merge(params, %{"site_id" => nil, "device_id" => nil}) - defp maybe_clear_scope(params, "site"), do: Map.put(params, "device_id", nil) - defp maybe_clear_scope(params, _), do: params + @doc false + def maybe_clear_scope(params, "org"), do: Map.merge(params, %{"site_id" => nil, "device_id" => nil}) + def maybe_clear_scope(params, "site"), do: Map.put(params, "device_id", nil) + def maybe_clear_scope(params, _), do: params - defp format_datetime_local(nil), do: "" - defp format_datetime_local(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%dT%H:%M") - defp format_datetime_local(_), do: "" + @doc false + def format_datetime_local(nil), do: "" + def format_datetime_local(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%dT%H:%M") + def format_datetime_local(_), do: "" defp save_window(socket, :create, params) do case Maintenance.create_window(params) do diff --git a/lib/towerops_web/live/mikrotik_backup_live/compare.ex b/lib/towerops_web/live/mikrotik_backup_live/compare.ex index 18e91a8d..8bf92f7e 100644 --- a/lib/towerops_web/live/mikrotik_backup_live/compare.ex +++ b/lib/towerops_web/live/mikrotik_backup_live/compare.ex @@ -115,15 +115,14 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do {:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})} end - # Helper function to format byte sizes - defp format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do - cond do - bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB" - bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB" - bytes >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB" - true -> "#{bytes} B" - end - end + @doc false + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_073_741_824, + do: "#{Float.round(bytes / 1_073_741_824, 1)} GB" - defp format_bytes(_), do: "Invalid byte count" + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB" + + def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_024, do: "#{Float.round(bytes / 1_024, 1)} KB" + + def format_bytes(bytes) when is_integer(bytes) and bytes >= 0, do: "#{bytes} B" + def format_bytes(_), do: "Invalid byte count" end diff --git a/lib/towerops_web/live/onboarding_live.ex b/lib/towerops_web/live/onboarding_live.ex index 8d107068..c7726c70 100644 --- a/lib/towerops_web/live/onboarding_live.ex +++ b/lib/towerops_web/live/onboarding_live.ex @@ -188,15 +188,17 @@ defmodule ToweropsWeb.OnboardingLive do # Private helpers - defp next_step(:snmp), do: :site - defp next_step(:site), do: :billing - defp next_step(:billing), do: :agent - defp next_step(:agent), do: :done + @doc false + def next_step(:snmp), do: :site + def next_step(:site), do: :billing + def next_step(:billing), do: :agent + def next_step(:agent), do: :done - defp step_index(:snmp), do: 0 - defp step_index(:site), do: 1 - defp step_index(:billing), do: 2 - defp step_index(:agent), do: 3 + @doc false + def step_index(:snmp), do: 0 + def step_index(:site), do: 1 + def step_index(:billing), do: 2 + def step_index(:agent), do: 3 defp step_label(:snmp), do: t("SNMP") defp step_label(:site), do: t("Site") @@ -231,7 +233,8 @@ defmodule ToweropsWeb.OnboardingLive do end end - defp normalize_integration_params(params, "sonar") do + @doc false + def normalize_integration_params(params, "sonar") do %{ credentials: %{ "instance_url" => String.trim(Map.get(params, "instance_url", "")), @@ -240,7 +243,7 @@ defmodule ToweropsWeb.OnboardingLive do } end - defp normalize_integration_params(params, "splynx") do + def normalize_integration_params(params, "splynx") do %{ credentials: %{ "instance_url" => String.trim(Map.get(params, "instance_url", "")), @@ -250,7 +253,7 @@ defmodule ToweropsWeb.OnboardingLive do } end - defp normalize_integration_params(params, _provider) do + def normalize_integration_params(params, _provider) do %{credentials: %{"api_key" => Map.get(params, "api_key", "")}} end end diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.ex b/lib/towerops_web/live/org/gaiia_mapping_live.ex index fbe9367a..a698699b 100644 --- a/lib/towerops_web/live/org/gaiia_mapping_live.ex +++ b/lib/towerops_web/live/org/gaiia_mapping_live.ex @@ -273,9 +273,10 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do |> assign(:suggestions, suggestions) end - defp add_device_suggestion(acc, %{inventory_item: item}, _) when not is_nil(item), do: acc + @doc false + def add_device_suggestion(acc, %{inventory_item: item}, _) when not is_nil(item), do: acc - defp add_device_suggestion(acc, row, unmapped_items_by_ip) do + def add_device_suggestion(acc, row, unmapped_items_by_ip) do device_ip = to_string(row.device.ip_address) matches = Map.get(unmapped_items_by_ip, device_ip, []) @@ -286,10 +287,11 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do end end - defp add_site_suggestion(acc, %{network_site: ns}, _) when not is_nil(ns), do: acc - defp add_site_suggestion(acc, %{site: %{name: name}}, _) when name in [nil, ""], do: acc + @doc false + def add_site_suggestion(acc, %{network_site: ns}, _) when not is_nil(ns), do: acc + def add_site_suggestion(acc, %{site: %{name: name}}, _) when name in [nil, ""], do: acc - defp add_site_suggestion(acc, row, unmapped_ns) do + def add_site_suggestion(acc, row, unmapped_ns) do site_name_lower = String.downcase(row.site.name) matches = @@ -306,11 +308,13 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do end end - defp apply_device_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.inventory_item))) - defp apply_device_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.inventory_item)) - defp apply_device_filter(rows, _), do: rows + @doc false + def apply_device_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.inventory_item))) + def apply_device_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.inventory_item)) + def apply_device_filter(rows, _), do: rows - defp apply_site_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.network_site))) - defp apply_site_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.network_site)) - defp apply_site_filter(rows, _), do: rows + @doc false + def apply_site_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.network_site))) + def apply_site_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.network_site)) + def apply_site_filter(rows, _), do: rows end diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index e5016ce1..d4b4fa64 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -546,9 +546,10 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp validate_present(nil, msg), do: {:error, msg} - defp validate_present("", msg), do: {:error, msg} - defp validate_present(_, _msg), do: :ok + @doc false + def validate_present(nil, msg), do: {:error, msg} + def validate_present("", msg), do: {:error, msg} + def validate_present(_, _msg), do: :ok # === Private helpers (integrations) === @@ -585,7 +586,8 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp normalize_params(params, existing_integration) do + @doc false + def normalize_params(params, existing_integration) do provider = case existing_integration do %Integration{provider: p} -> p @@ -600,7 +602,8 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp normalize_standard_params(params, existing_integration) do + @doc false + def normalize_standard_params(params, existing_integration) do api_key = Map.get(params, "api_key", "") sync_interval = Map.get(params, "sync_interval_minutes") @@ -629,7 +632,8 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp normalize_netbox_params(params, _existing_integration) do + @doc false + def normalize_netbox_params(params, _existing_integration) do sync_interval = Map.get(params, "sync_interval_minutes") credentials = %{ @@ -654,13 +658,14 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp get_credential(nil, _key), do: "" + @doc false + def get_credential(nil, _key), do: "" - defp get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do + def get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do Map.get(credentials, key, "") end - defp get_credential(_integration, _key), do: "" + def get_credential(_integration, _key), do: "" defp current_integration(socket) do provider = socket.assigns.configuring @@ -695,7 +700,8 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp normalize_sonar_params(params) do + @doc false + def normalize_sonar_params(params) do sync_interval = Map.get(params, "sync_interval_minutes") attrs = %{ @@ -712,7 +718,8 @@ defmodule ToweropsWeb.Org.SettingsLive do end end - defp normalize_splynx_params(params) do + @doc false + def normalize_splynx_params(params) do sync_interval = Map.get(params, "sync_interval_minutes") attrs = %{ @@ -753,9 +760,10 @@ defmodule ToweropsWeb.Org.SettingsLive do {instance_url, api_key, api_secret} end - defp non_empty("", fallback), do: fallback - defp non_empty(nil, fallback), do: fallback - defp non_empty(value, _fallback), do: value + @doc false + def non_empty("", fallback), do: fallback + def non_empty(nil, fallback), do: fallback + def non_empty(value, _fallback), do: value defp error_messages(changeset) do changeset @@ -767,29 +775,31 @@ defmodule ToweropsWeb.Org.SettingsLive do |> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end) end - defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"} - defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"} - defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"} + @doc false + def format_connection_result({:ok, _body}), do: {:ok, "Connection successful"} + def format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"} + def format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"} - defp format_connection_result({:error, {:unexpected_status, status}}), + def format_connection_result({:error, {:unexpected_status, status}}), do: {:error, "API returned unexpected status: #{status}"} - defp format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"} + def format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"} - defp format_connection_result({:error, %{reason: :econnrefused}}), + def format_connection_result({:error, %{reason: :econnrefused}}), do: {:error, "Connection refused - check API endpoint"} - defp format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"} + def format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"} - defp format_connection_result({:error, {:graphql_errors, errors}}), + def format_connection_result({:error, {:graphql_errors, errors}}), do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"} - defp format_connection_result({:error, reason}) do + def format_connection_result({:error, reason}) do Logger.warning("Integration connection test failed: #{inspect(reason)}") {:error, "Connection failed - please check your API credentials and try again"} end - defp default_netbox_config do + @doc false + def default_netbox_config do %{ "url" => "", "api_token" => "", @@ -804,9 +814,10 @@ defmodule ToweropsWeb.Org.SettingsLive do } end - defp load_netbox_config(nil), do: default_netbox_config() + @doc false + def load_netbox_config(nil), do: default_netbox_config() - defp load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do + def load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do %{ "url" => Map.get(creds, "url", ""), "api_token" => Map.get(creds, "api_token", ""), @@ -821,16 +832,17 @@ defmodule ToweropsWeb.Org.SettingsLive do } end - defp load_netbox_config(_), do: default_netbox_config() + def load_netbox_config(_), do: default_netbox_config() - defp load_integration_credentials(integration, "sonar") do + @doc false + def load_integration_credentials(integration, "sonar") do %{ "instance_url" => get_credential(integration, "instance_url"), "api_token" => get_credential(integration, "api_token") } end - defp load_integration_credentials(integration, "splynx") do + def load_integration_credentials(integration, "splynx") do %{ "instance_url" => get_credential(integration, "instance_url"), "api_key" => get_credential(integration, "api_key"), @@ -838,18 +850,19 @@ defmodule ToweropsWeb.Org.SettingsLive do } end - defp load_integration_credentials(integration, _provider) do + def load_integration_credentials(integration, _provider) do %{"api_key" => get_credential(integration, "api_key")} end - defp extract_credentials_from_params(params, "sonar") do + @doc false + def extract_credentials_from_params(params, "sonar") do %{ "instance_url" => Map.get(params, "instance_url", ""), "api_token" => Map.get(params, "api_token", "") } end - defp extract_credentials_from_params(params, "splynx") do + def extract_credentials_from_params(params, "splynx") do %{ "instance_url" => Map.get(params, "instance_url", ""), "api_key" => Map.get(params, "api_key", ""), @@ -857,7 +870,7 @@ defmodule ToweropsWeb.Org.SettingsLive do } end - defp extract_credentials_from_params(params, _provider) do + def extract_credentials_from_params(params, _provider) do %{"api_key" => Map.get(params, "api_key", "")} end diff --git a/lib/towerops_web/live/reports_live.ex b/lib/towerops_web/live/reports_live.ex index 7a27bb40..074c86ad 100644 --- a/lib/towerops_web/live/reports_live.ex +++ b/lib/towerops_web/live/reports_live.ex @@ -116,24 +116,27 @@ defmodule ToweropsWeb.ReportsLive do # -- Helpers -- - defp humanize_type("uptime_summary"), do: "Uptime Summary" - defp humanize_type("alert_history"), do: "Alert History" - defp humanize_type("capacity_trends"), do: "Capacity Trends" - defp humanize_type("rf_link_health"), do: "RF Link Health" - defp humanize_type(type), do: type + @doc false + def humanize_type("uptime_summary"), do: "Uptime Summary" + def humanize_type("alert_history"), do: "Alert History" + def humanize_type("capacity_trends"), do: "Capacity Trends" + def humanize_type("rf_link_health"), do: "RF Link Health" + def humanize_type(type), do: type - defp humanize_schedule(%{"type" => type}), do: String.capitalize(type) - defp humanize_schedule(_), do: "-" + @doc false + def humanize_schedule(%{"type" => type}), do: String.capitalize(type) + def humanize_schedule(_), do: "-" - defp format_recipients(recipients) when is_list(recipients), do: Enum.join(recipients, ", ") - defp format_recipients(_), do: "-" + @doc false + def format_recipients(recipients) when is_list(recipients), do: Enum.join(recipients, ", ") + def format_recipients(_), do: "-" - defp status_badge("success"), do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"} + @doc false + def status_badge("success"), do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"} + def status_badge("failed"), do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"} - defp status_badge("failed"), do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"} - - defp status_badge("partial_failure"), + def status_badge("partial_failure"), do: {"Partial", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"} - defp status_badge(_), do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"} + def status_badge(_), do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"} end diff --git a/lib/towerops_web/live/rf_link_health_live.ex b/lib/towerops_web/live/rf_link_health_live.ex index f70d9b0c..1c1d4f63 100644 --- a/lib/towerops_web/live/rf_link_health_live.ex +++ b/lib/towerops_web/live/rf_link_health_live.ex @@ -80,101 +80,105 @@ defmodule ToweropsWeb.RfLinkHealthLive do # -- Helpers -- - defp health_color(:healthy), do: "text-green-600 dark:text-green-400" - defp health_color(:degraded), do: "text-yellow-600 dark:text-yellow-400" - defp health_color(:critical), do: "text-red-600 dark:text-red-400" - defp health_color(_), do: "text-gray-400 dark:text-gray-500" + @doc false + def health_color(:healthy), do: "text-green-600 dark:text-green-400" + def health_color(:degraded), do: "text-yellow-600 dark:text-yellow-400" + def health_color(:critical), do: "text-red-600 dark:text-red-400" + def health_color(_), do: "text-gray-400 dark:text-gray-500" - defp health_bg(:healthy), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" - defp health_bg(:degraded), do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300" - defp health_bg(:critical), do: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300" - defp health_bg(_), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" + @doc false + def health_bg(:healthy), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" + def health_bg(:degraded), do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300" + def health_bg(:critical), do: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300" + def health_bg(_), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" - defp health_label(:healthy), do: "Healthy" - defp health_label(:degraded), do: "Degraded" - defp health_label(:critical), do: "Critical" - defp health_label(_), do: "Unknown" + @doc false + def health_label(:healthy), do: "Healthy" + def health_label(:degraded), do: "Degraded" + def health_label(:critical), do: "Critical" + def health_label(_), do: "Unknown" - defp signal_bar_width(nil), do: 0 + @doc false + def signal_bar_width(nil), do: 0 - defp signal_bar_width(signal) do + def signal_bar_width(signal) do # Map signal from -100..-30 dBm to 0..100% clamped = max(-100, min(-30, signal)) round((clamped + 100) / 70 * 100) end - defp signal_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600" + @doc false + def signal_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600" + def signal_bar_color(signal) when signal > -65, do: "bg-green-500" + def signal_bar_color(signal) when signal > -75, do: "bg-yellow-500" + def signal_bar_color(_), do: "bg-red-500" - defp signal_bar_color(signal) do - cond do - signal > -65 -> "bg-green-500" - signal > -75 -> "bg-yellow-500" - true -> "bg-red-500" - end - end + @doc false + def snr_bar_width(nil), do: 0 - defp snr_bar_width(nil), do: 0 - - defp snr_bar_width(snr) do + def snr_bar_width(snr) do # Map SNR from 0..40 dB to 0..100% clamped = max(0, min(40, snr)) round(clamped / 40 * 100) end - defp snr_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600" + @doc false + def snr_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600" + def snr_bar_color(snr) when snr > 25, do: "bg-green-500" + def snr_bar_color(snr) when snr > 15, do: "bg-yellow-500" + def snr_bar_color(_), do: "bg-red-500" - defp snr_bar_color(snr) do - cond do - snr > 25 -> "bg-green-500" - snr > 15 -> "bg-yellow-500" - true -> "bg-red-500" - end - end + @doc false + def format_signal(nil), do: "-" + def format_signal(val), do: "#{val} dBm" - defp format_signal(nil), do: "-" - defp format_signal(val), do: "#{val} dBm" + @doc false + def format_snr(nil), do: "-" + def format_snr(val), do: "#{val} dB" - defp format_snr(nil), do: "-" - defp format_snr(val), do: "#{val} dB" + @doc false + def format_rate(nil), do: "-" + def format_rate(rate) when rate >= 1000, do: "#{Float.round(rate / 1000, 1)} Gbps" + def format_rate(rate), do: "#{rate} Mbps" - defp format_rate(nil), do: "-" + @doc false + def format_distance(nil), do: "-" + def format_distance(m) when m >= 1000, do: "#{Float.round(m / 1000, 1)} km" + def format_distance(m), do: "#{m} m" - defp format_rate(rate) when rate >= 1000, do: "#{Float.round(rate / 1000, 1)} Gbps" + @doc false + def format_uptime(nil), do: "-" - defp format_rate(rate), do: "#{rate} Mbps" - - defp format_distance(nil), do: "-" - defp format_distance(m) when m >= 1000, do: "#{Float.round(m / 1000, 1)} km" - defp format_distance(m), do: "#{m} m" - - defp format_uptime(nil), do: "-" - - defp format_uptime(seconds) do + def format_uptime(seconds) do days = div(seconds, 86_400) hours = div(rem(seconds, 86_400), 3600) - cond do - days > 0 -> "#{days}d #{hours}h" - hours > 0 -> "#{hours}h" - true -> "< 1h" - end + format_uptime_parts(days, hours) end - defp device_name(%{device: %{name: name}}) when not is_nil(name), do: name - defp device_name(%{device: %{ip_address: ip}}) when not is_nil(ip), do: ip - defp device_name(_), do: "-" + defp format_uptime_parts(days, hours) when days > 0, do: "#{days}d #{hours}h" + defp format_uptime_parts(_days, hours) when hours > 0, do: "#{hours}h" + defp format_uptime_parts(_, _), do: "< 1h" - defp link_name(link) do + @doc false + def device_name(%{device: %{name: name}}) when not is_nil(name), do: name + def device_name(%{device: %{ip_address: ip}}) when not is_nil(ip), do: ip + def device_name(_), do: "-" + + @doc false + def link_name(link) do link.hostname || link.ip_address || link.mac_address || "-" end - defp stat_card_class(:all, _), do: "ring-2 ring-blue-500" - defp stat_card_class(filter, filter), do: "ring-2 ring-blue-500" - defp stat_card_class(_, _), do: "" + @doc false + def stat_card_class(:all, _), do: "ring-2 ring-blue-500" + def stat_card_class(filter, filter), do: "ring-2 ring-blue-500" + def stat_card_class(_, _), do: "" - defp build_sparkline_points(readings) when length(readings) < 2, do: nil + @doc false + def build_sparkline_points(readings) when length(readings) < 2, do: nil - defp build_sparkline_points(readings) do + def build_sparkline_points(readings) do signals = readings |> Enum.map(& &1.signal_strength) |> Enum.reject(&is_nil/1) if signals == [] do diff --git a/lib/towerops_web/live/site_live/index.ex b/lib/towerops_web/live/site_live/index.ex index 152f19b7..dd15b2aa 100644 --- a/lib/towerops_web/live/site_live/index.ex +++ b/lib/towerops_web/live/site_live/index.ex @@ -51,32 +51,37 @@ defmodule ToweropsWeb.SiteLive.Index do assign(socket, :page_title, t("Sites")) end - defp format_qoe(nil), do: "—" - defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1) + @doc false + def format_qoe(nil), do: "—" + def format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1) - defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500" - defp qoe_color(score) when score < 2.0, do: "text-red-600 dark:text-red-400" - defp qoe_color(score) when score < 4.0, do: "text-yellow-600 dark:text-yellow-400" - defp qoe_color(_), do: "text-green-600 dark:text-green-400" + @doc false + def qoe_color(nil), do: "text-gray-400 dark:text-gray-500" + def qoe_color(score) when score < 2.0, do: "text-red-600 dark:text-red-400" + def qoe_color(score) when score < 4.0, do: "text-yellow-600 dark:text-yellow-400" + def qoe_color(_), do: "text-green-600 dark:text-green-400" - defp health_dot(nil), do: "bg-gray-400" - defp health_dot(:red), do: "bg-red-500" - defp health_dot(:yellow), do: "bg-yellow-500" - defp health_dot(_), do: "bg-green-500" + @doc false + def health_dot(nil), do: "bg-gray-400" + def health_dot(:red), do: "bg-red-500" + def health_dot(:yellow), do: "bg-yellow-500" + def health_dot(_), do: "bg-green-500" - defp urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + @doc false + def urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" - defp urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + def urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" - defp urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" + def urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" - defp urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + def urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" - defp source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400" + @doc false + def source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400" - defp source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400" + def source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400" - defp source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400" + def source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400" - defp source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + def source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" end diff --git a/lib/towerops_web/live/site_live/show.ex b/lib/towerops_web/live/site_live/show.ex index f4684f2f..74262d4a 100644 --- a/lib/towerops_web/live/site_live/show.ex +++ b/lib/towerops_web/live/site_live/show.ex @@ -163,86 +163,96 @@ defmodule ToweropsWeb.SiteLive.Show do defp format_number(number), do: to_string(number) - defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500" - defp qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400" - defp qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400" - defp qoe_color(_), do: "text-red-600 dark:text-red-400" + @doc false + def qoe_color(nil), do: "text-gray-400 dark:text-gray-500" + def qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400" + def qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400" + def qoe_color(_), do: "text-red-600 dark:text-red-400" - defp qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10" - defp qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30" + @doc false + def qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10" + def qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30" - defp qoe_bg(score) when score >= 6.0, + def qoe_bg(score) when score >= 6.0, do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30" - defp qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30" + def qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30" - defp capacity_bar_color(nil), do: "bg-gray-300" - defp capacity_bar_color(score) when score >= 70, do: "bg-green-500" - defp capacity_bar_color(score) when score >= 40, do: "bg-yellow-500" - defp capacity_bar_color(_), do: "bg-red-500" + @doc false + def capacity_bar_color(nil), do: "bg-gray-300" + def capacity_bar_color(score) when score >= 70, do: "bg-green-500" + def capacity_bar_color(score) when score >= 40, do: "bg-yellow-500" + def capacity_bar_color(_), do: "bg-red-500" - defp status_dot_class(:up), do: "bg-green-500" - defp status_dot_class(:down), do: "bg-red-500" - defp status_dot_class(_), do: "bg-gray-400" + @doc false + def status_dot_class(:up), do: "bg-green-500" + def status_dot_class(:down), do: "bg-red-500" + def status_dot_class(_), do: "bg-gray-400" - defp time_ago(nil), do: "—" + @doc false + def time_ago(nil), do: "—" - defp time_ago(datetime) do + def time_ago(datetime) do seconds = DateTime.diff(DateTime.utc_now(), datetime, :second) - - cond do - seconds < 60 -> "#{seconds}s ago" - seconds < 3600 -> "#{div(seconds, 60)}m ago" - seconds < 86_400 -> "#{div(seconds, 3600)}h ago" - true -> "#{div(seconds, 86_400)}d ago" - end + format_seconds_ago(seconds) end - defp format_response_time(nil), do: "—" - defp format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms" - defp format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms" - defp format_response_time(_), do: "—" + defp format_seconds_ago(seconds) when seconds < 60, do: "#{seconds}s ago" + defp format_seconds_ago(seconds) when seconds < 3600, do: "#{div(seconds, 60)}m ago" + defp format_seconds_ago(seconds) when seconds < 86_400, do: "#{div(seconds, 3600)}h ago" + defp format_seconds_ago(seconds), do: "#{div(seconds, 86_400)}d ago" - defp change_size_label(size) when size < 100, do: "Small" - defp change_size_label(size) when size < 500, do: "Medium" - defp change_size_label(_), do: "Large" + @doc false + def format_response_time(nil), do: "—" + def format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms" + def format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms" + def format_response_time(_), do: "—" - defp change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" + @doc false + def change_size_label(size) when size < 100, do: "Small" + def change_size_label(size) when size < 500, do: "Medium" + def change_size_label(_), do: "Large" - defp change_size_color(size) when size < 500, + @doc false + def change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" + + def change_size_color(size) when size < 500, do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" - defp change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + def change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" - defp insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400" - defp insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400" - defp insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400" + @doc false + def insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400" + def insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400" + def insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400" - defp insight_urgency_icon("critical"), do: "hero-exclamation-triangle" - defp insight_urgency_icon("warning"), do: "hero-exclamation-circle" - defp insight_urgency_icon(_), do: "hero-information-circle" + @doc false + def insight_urgency_icon("critical"), do: "hero-exclamation-triangle" + def insight_urgency_icon("warning"), do: "hero-exclamation-circle" + def insight_urgency_icon(_), do: "hero-information-circle" - defp alert_severity(:device_down), do: "critical" - defp alert_severity(:device_up), do: "info" - defp alert_severity(_), do: "warning" + @doc false + def alert_severity(:device_down), do: "critical" + def alert_severity(:device_up), do: "info" + def alert_severity(_), do: "warning" - defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, - do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps" + @doc false + def format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps" - defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps" + def format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps" + def format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps" + def format_capacity(bps) when is_number(bps), do: "#{bps} bps" + def format_capacity(_), do: "-" - defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps" + @doc false + def utilization_bar_color(pct) when pct >= 90, do: "bg-red-500" + def utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500" + def utilization_bar_color(_pct), do: "bg-green-500" - defp format_capacity(bps) when is_number(bps), do: "#{bps} bps" - defp format_capacity(_), do: "-" - - defp utilization_bar_color(pct) when pct >= 90, do: "bg-red-500" - defp utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500" - defp utilization_bar_color(_pct), do: "bg-green-500" - - defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" - defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" - defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" + @doc false + def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" + def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" + def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" # Enqueue discovery job - safe to call in test environment defp enqueue_discovery(device_id) do diff --git a/lib/towerops_web/live/status_page_live.ex b/lib/towerops_web/live/status_page_live.ex index 27696705..9b9fe742 100644 --- a/lib/towerops_web/live/status_page_live.ex +++ b/lib/towerops_web/live/status_page_live.ex @@ -61,41 +61,47 @@ defmodule ToweropsWeb.StatusPageLive do # -- Helpers -- - defp overall_color(:operational), do: "bg-green-500" - defp overall_color(:degraded), do: "bg-yellow-500" - defp overall_color(:partial_outage), do: "bg-orange-500" - defp overall_color(:major_outage), do: "bg-red-500" - defp overall_color(:maintenance), do: "bg-blue-500" - defp overall_color(_), do: "bg-gray-500" + @doc false + def overall_color(:operational), do: "bg-green-500" + def overall_color(:degraded), do: "bg-yellow-500" + def overall_color(:partial_outage), do: "bg-orange-500" + def overall_color(:major_outage), do: "bg-red-500" + def overall_color(:maintenance), do: "bg-blue-500" + def overall_color(_), do: "bg-gray-500" - defp overall_text(:operational), do: "All Systems Operational" - defp overall_text(:degraded), do: "Degraded Performance" - defp overall_text(:partial_outage), do: "Partial System Outage" - defp overall_text(:major_outage), do: "Major System Outage" - defp overall_text(:maintenance), do: "Scheduled Maintenance" - defp overall_text(_), do: "Unknown" + @doc false + def overall_text(:operational), do: "All Systems Operational" + def overall_text(:degraded), do: "Degraded Performance" + def overall_text(:partial_outage), do: "Partial System Outage" + def overall_text(:major_outage), do: "Major System Outage" + def overall_text(:maintenance), do: "Scheduled Maintenance" + def overall_text(_), do: "Unknown" - defp component_color("operational"), do: "bg-green-500" - defp component_color("degraded"), do: "bg-yellow-500" - defp component_color("partial_outage"), do: "bg-orange-500" - defp component_color("major_outage"), do: "bg-red-500" - defp component_color("maintenance"), do: "bg-blue-500" - defp component_color(_), do: "bg-gray-400" + @doc false + def component_color("operational"), do: "bg-green-500" + def component_color("degraded"), do: "bg-yellow-500" + def component_color("partial_outage"), do: "bg-orange-500" + def component_color("major_outage"), do: "bg-red-500" + def component_color("maintenance"), do: "bg-blue-500" + def component_color(_), do: "bg-gray-400" - defp component_label("operational"), do: "Operational" - defp component_label("degraded"), do: "Degraded" - defp component_label("partial_outage"), do: "Partial Outage" - defp component_label("major_outage"), do: "Major Outage" - defp component_label("maintenance"), do: "Maintenance" - defp component_label(s), do: s + @doc false + def component_label("operational"), do: "Operational" + def component_label("degraded"), do: "Degraded" + def component_label("partial_outage"), do: "Partial Outage" + def component_label("major_outage"), do: "Major Outage" + def component_label("maintenance"), do: "Maintenance" + def component_label(s), do: s - defp severity_color("critical"), do: "text-red-600" - defp severity_color("major"), do: "text-orange-600" - defp severity_color(_), do: "text-yellow-600" + @doc false + def severity_color("critical"), do: "text-red-600" + def severity_color("major"), do: "text-orange-600" + def severity_color(_), do: "text-yellow-600" - defp incident_status_label("investigating"), do: "Investigating" - defp incident_status_label("identified"), do: "Identified" - defp incident_status_label("monitoring"), do: "Monitoring" - defp incident_status_label("resolved"), do: "Resolved" - defp incident_status_label(s), do: s + @doc false + def incident_status_label("investigating"), do: "Investigating" + def incident_status_label("identified"), do: "Identified" + def incident_status_label("monitoring"), do: "Monitoring" + def incident_status_label("resolved"), do: "Resolved" + def incident_status_label(s), do: s end diff --git a/lib/towerops_web/live/trace_live/index.ex b/lib/towerops_web/live/trace_live/index.ex index 599f4aed..90a7c526 100644 --- a/lib/towerops_web/live/trace_live/index.ex +++ b/lib/towerops_web/live/trace_live/index.ex @@ -138,116 +138,134 @@ defmodule ToweropsWeb.TraceLive.Index do |> push_patch(to: ~p"/trace")} end - defp type_badge_class(:account), do: "badge-primary" - defp type_badge_class(:inventory_item), do: "badge-secondary" - defp type_badge_class(:site), do: "badge-warning" - defp type_badge_class(:device), do: "badge-accent" - defp type_badge_class(:access_point), do: "badge-info" - defp type_badge_class(_), do: "badge-ghost" + def type_badge_class(:account), do: "badge-primary" + def type_badge_class(:inventory_item), do: "badge-secondary" + def type_badge_class(:site), do: "badge-warning" + def type_badge_class(:device), do: "badge-accent" + def type_badge_class(:access_point), do: "badge-info" + def type_badge_class(_), do: "badge-ghost" - defp type_badge_label(:account), do: "Account" - defp type_badge_label(:inventory_item), do: "Inventory" - defp type_badge_label(:site), do: "Site" - defp type_badge_label(:device), do: "Device" - defp type_badge_label(:access_point), do: "AP" - defp type_badge_label(_), do: "Unknown" + def type_badge_label(:account), do: "Account" + def type_badge_label(:inventory_item), do: "Inventory" + def type_badge_label(:site), do: "Site" + def type_badge_label(:device), do: "Device" + def type_badge_label(:access_point), do: "AP" + def type_badge_label(_), do: "Unknown" - defp status_badge_class("active"), do: "badge-success" - defp status_badge_class("suspended"), do: "badge-warning" - defp status_badge_class("cancelled"), do: "badge-error" - defp status_badge_class(_), do: "badge-ghost" + @doc false + def status_badge_class("active"), do: "badge-success" + def status_badge_class("suspended"), do: "badge-warning" + def status_badge_class("cancelled"), do: "badge-error" + def status_badge_class(_), do: "badge-ghost" - defp device_status_color(:up), do: "text-green-600 dark:text-green-400" - defp device_status_color(:down), do: "text-red-600 dark:text-red-400" - defp device_status_color(_), do: "text-gray-500 dark:text-gray-400" + @doc false + def device_status_color(:up), do: "text-green-600 dark:text-green-400" + def device_status_color(:down), do: "text-red-600 dark:text-red-400" + def device_status_color(_), do: "text-gray-500 dark:text-gray-400" - defp format_speed(nil), do: "—" - defp format_speed(speed) when is_number(speed) and speed >= 1000, do: "#{Float.round(speed / 1000, 1)} Gbps" - defp format_speed(speed) when is_number(speed), do: "#{speed} Mbps" - defp format_speed(speed), do: "#{speed}" + @doc false + def format_speed(nil), do: "—" + def format_speed(speed) when is_number(speed) and speed >= 1000, do: "#{Float.round(speed / 1000, 1)} Gbps" + def format_speed(speed) when is_number(speed), do: "#{speed} Mbps" + def format_speed(speed), do: "#{speed}" - defp format_relative_time(nil), do: "—" + @doc false + def format_relative_time(nil), do: "—" - defp format_relative_time(dt) do + def format_relative_time(dt) do diff = DateTime.diff(DateTime.utc_now(), dt, :second) - - cond do - diff < 60 -> "just now" - diff < 3600 -> "#{div(diff, 60)}m ago" - diff < 86_400 -> "#{div(diff, 3600)}h ago" - true -> "#{div(diff, 86_400)}d ago" - end + format_relative_diff(diff) end - defp format_score(nil), do: "—" - defp format_score(score) when is_number(score), do: "#{Float.round(score * 1.0, 1)}" - defp format_score(score), do: "#{score}" + defp format_relative_diff(diff) when diff < 60, do: "just now" + defp format_relative_diff(diff) when diff < 3600, do: "#{div(diff, 60)}m ago" + defp format_relative_diff(diff) when diff < 86_400, do: "#{div(diff, 3600)}h ago" + defp format_relative_diff(diff), do: "#{div(diff, 86_400)}d ago" - defp format_pct(nil), do: "—" - defp format_pct(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)}%" - defp format_pct(val), do: "#{val}" + @doc false + def format_score(nil), do: "—" + def format_score(score) when is_number(score), do: "#{Float.round(score * 1.0, 1)}" + def format_score(score), do: "#{score}" - defp format_ms(nil), do: "—" - defp format_ms(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} ms" - defp format_ms(val), do: "#{val}" + @doc false + def format_pct(nil), do: "—" + def format_pct(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)}%" + def format_pct(val), do: "#{val}" - defp format_throughput(nil), do: "—" - defp format_throughput(val) when is_number(val) and val >= 1000, do: "#{Float.round(val / 1000, 1)} Gbps" - defp format_throughput(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} Mbps" - defp format_throughput(val), do: "#{val}" + @doc false + def format_ms(nil), do: "—" + def format_ms(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} ms" + def format_ms(val), do: "#{val}" - defp format_alert_type(nil), do: "—" - defp format_alert_type(type) when is_binary(type), do: type |> String.replace("_", " ") |> String.capitalize() - defp format_alert_type(type), do: "#{type}" + @doc false + def format_throughput(nil), do: "—" + def format_throughput(val) when is_number(val) and val >= 1000, do: "#{Float.round(val / 1000, 1)} Gbps" + def format_throughput(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} Mbps" + def format_throughput(val), do: "#{val}" - defp score_quality(nil), do: :unknown - defp score_quality(s) when s >= 80, do: :good - defp score_quality(s) when s >= 50, do: :warning - defp score_quality(_), do: :bad + @doc false + def format_alert_type(nil), do: "—" + def format_alert_type(type) when is_binary(type), do: type |> String.replace("_", " ") |> String.capitalize() + def format_alert_type(type), do: "#{type}" - defp score_color(nil), do: "text-gray-400" - defp score_color(s) when s >= 80, do: "text-green-600 dark:text-green-400" - defp score_color(s) when s >= 50, do: "text-yellow-600 dark:text-yellow-400" - defp score_color(_), do: "text-red-600 dark:text-red-400" + @doc false + def score_quality(nil), do: :unknown + def score_quality(s) when s >= 80, do: :good + def score_quality(s) when s >= 50, do: :warning + def score_quality(_), do: :bad - defp latency_color(nil), do: "text-gray-400" - defp latency_color(v) when v <= 30, do: "text-green-600 dark:text-green-400" - defp latency_color(v) when v <= 80, do: "text-yellow-600 dark:text-yellow-400" - defp latency_color(_), do: "text-red-600 dark:text-red-400" + @doc false + def score_color(nil), do: "text-gray-400" + def score_color(s) when s >= 80, do: "text-green-600 dark:text-green-400" + def score_color(s) when s >= 50, do: "text-yellow-600 dark:text-yellow-400" + def score_color(_), do: "text-red-600 dark:text-red-400" - defp jitter_color(nil), do: "text-gray-400" - defp jitter_color(v) when v <= 10, do: "text-green-600 dark:text-green-400" - defp jitter_color(v) when v <= 30, do: "text-yellow-600 dark:text-yellow-400" - defp jitter_color(_), do: "text-red-600 dark:text-red-400" + @doc false + def latency_color(nil), do: "text-gray-400" + def latency_color(v) when v <= 30, do: "text-green-600 dark:text-green-400" + def latency_color(v) when v <= 80, do: "text-yellow-600 dark:text-yellow-400" + def latency_color(_), do: "text-red-600 dark:text-red-400" - defp loss_color(nil), do: "text-gray-400" - defp loss_color(v) when v <= 1, do: "text-green-600 dark:text-green-400" - defp loss_color(v) when v <= 5, do: "text-yellow-600 dark:text-yellow-400" - defp loss_color(_), do: "text-red-600 dark:text-red-400" + @doc false + def jitter_color(nil), do: "text-gray-400" + def jitter_color(v) when v <= 10, do: "text-green-600 dark:text-green-400" + def jitter_color(v) when v <= 30, do: "text-yellow-600 dark:text-yellow-400" + def jitter_color(_), do: "text-red-600 dark:text-red-400" - defp device_status_dot(:up), do: "bg-green-500" - defp device_status_dot(:down), do: "bg-red-500" - defp device_status_dot(_), do: "bg-gray-400" + @doc false + def loss_color(nil), do: "text-gray-400" + def loss_color(v) when v <= 1, do: "text-green-600 dark:text-green-400" + def loss_color(v) when v <= 5, do: "text-yellow-600 dark:text-yellow-400" + def loss_color(_), do: "text-red-600 dark:text-red-400" - defp alert_dot(%{severity: "critical"}), do: "bg-red-500" - defp alert_dot(%{severity: "warning"}), do: "bg-yellow-500" - defp alert_dot(%{alert_type: "down"}), do: "bg-red-500" - defp alert_dot(_), do: "bg-orange-400" + @doc false + def device_status_dot(:up), do: "bg-green-500" + def device_status_dot(:down), do: "bg-red-500" + def device_status_dot(_), do: "bg-gray-400" - defp urgency_dot("critical"), do: "bg-red-500" - defp urgency_dot("high"), do: "bg-orange-500" - defp urgency_dot("medium"), do: "bg-yellow-500" - defp urgency_dot(_), do: "bg-blue-400" + @doc false + def alert_dot(%{severity: "critical"}), do: "bg-red-500" + def alert_dot(%{severity: "warning"}), do: "bg-yellow-500" + def alert_dot(%{alert_type: "down"}), do: "bg-red-500" + def alert_dot(_), do: "bg-orange-400" - defp airtime_quality(nil), do: :unknown - defp airtime_quality(v) when v <= 50, do: :good - defp airtime_quality(v) when v <= 80, do: :warning - defp airtime_quality(_), do: :bad + @doc false + def urgency_dot("critical"), do: "bg-red-500" + def urgency_dot("high"), do: "bg-orange-500" + def urgency_dot("medium"), do: "bg-yellow-500" + def urgency_dot(_), do: "bg-blue-400" - defp safe_type("account"), do: :account - defp safe_type("inventory_item"), do: :inventory_item - defp safe_type("site"), do: :site - defp safe_type("device"), do: :device - defp safe_type("access_point"), do: :access_point - defp safe_type(_), do: :account + @doc false + def airtime_quality(nil), do: :unknown + def airtime_quality(v) when v <= 50, do: :good + def airtime_quality(v) when v <= 80, do: :warning + def airtime_quality(_), do: :bad + + @doc false + def safe_type("account"), do: :account + def safe_type("inventory_item"), do: :inventory_item + def safe_type("site"), do: :site + def safe_type("device"), do: :device + def safe_type("access_point"), do: :access_point + def safe_type(_), do: :account end diff --git a/lib/towerops_web/live/user_registration_live.ex b/lib/towerops_web/live/user_registration_live.ex index 0a44451a..00685e89 100644 --- a/lib/towerops_web/live/user_registration_live.ex +++ b/lib/towerops_web/live/user_registration_live.ex @@ -76,7 +76,8 @@ defmodule ToweropsWeb.UserRegistrationLive do end end - defp normalize_consent_params(params) do + @doc false + def normalize_consent_params(params) do params |> Map.update("privacy_policy_consent", nil, fn "on" -> true diff --git a/mix.exs b/mix.exs index 9aa0adbc..4369b238 100644 --- a/mix.exs +++ b/mix.exs @@ -13,7 +13,21 @@ defmodule Towerops.MixProject do compilers: [:phoenix_live_view] ++ Mix.compilers(), prune_code_paths: false, listeners: [Phoenix.CodeReloader], - dialyzer: dialyzer() + dialyzer: dialyzer(), + test_coverage: [ignore_modules: ignore_modules_for_coverage()] + ] + end + + # Vendored SnmpKit library, generated protobuf modules, and purely + # declarative Phoenix/Absinthe type modules are excluded from coverage + # calculations since they are third-party, generated, or data-only code. + defp ignore_modules_for_coverage do + [ + ~r/^SnmpKit/, + ~r/^Towerops\.Agent\./, + ~r/^Inspect\./, + ~r/^ToweropsWeb\.GraphQL\.Types\./, + ~r/HTML$/ ] end diff --git a/test/mix/tasks/geoip_import_test.exs b/test/mix/tasks/geoip_import_test.exs new file mode 100644 index 00000000..170f02f6 --- /dev/null +++ b/test/mix/tasks/geoip_import_test.exs @@ -0,0 +1,136 @@ +defmodule Mix.Tasks.Geoip.ImportUnitTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Geoip.Import + + describe "parse_geoname_id/1" do + test "empty string returns nil" do + assert nil == Import.parse_geoname_id("") + end + + test "valid integer returns the integer" do + assert 12_345 == Import.parse_geoname_id("12345") + end + + test "invalid format returns nil" do + assert nil == Import.parse_geoname_id("abc") + end + + test "partial integer parses leading number" do + assert 123 == Import.parse_geoname_id("123abc") + end + end + + describe "parse_cidr/1" do + test "valid IPv4 /24 CIDR" do + assert {:ok, start_int, end_int} = Import.parse_cidr("192.168.1.0/24") + # 192.168.1.0 = 3232235776 + assert start_int == 3_232_235_776 + # 192.168.1.255 = 3232236031 + assert end_int == 3_232_236_031 + assert end_int - start_int == 255 + end + + test "valid /32 host address" do + assert {:ok, same, same} = Import.parse_cidr("10.0.0.1/32") + assert same == 10 * 16_777_216 + 1 + end + + test "valid /0 full range" do + assert {:ok, 0, end_int} = Import.parse_cidr("0.0.0.0/0") + assert end_int == 4_294_967_295 + end + + test "invalid CIDR returns :error" do + assert :error == Import.parse_cidr("not a cidr") + assert :error == Import.parse_cidr("192.168.1.0") + assert :error == Import.parse_cidr("256.256.256.256/24") + end + end + + describe "parse_block_line/1" do + test "well-formed line with geoname_id" do + line = "192.168.1.0/24,12345,67890,,,,0,1,,\n" + result = Import.parse_block_line(line) + + assert result.network == "192.168.1.0/24" + assert result.geoname_id == 12_345 + assert result.registered_country_geoname_id == 67_890 + end + + test "empty geoname_id falls back to registered" do + line = "192.168.1.0/24,,67890,,,,,,,\n" + result = Import.parse_block_line(line) + + assert result.geoname_id == 67_890 + assert result.registered_country_geoname_id == 67_890 + end + + test "both empty returns nil" do + line = "192.168.1.0/24,,,,,,,,,\n" + assert nil == Import.parse_block_line(line) + end + + test "malformed line returns nil" do + assert nil == Import.parse_block_line("no commas\n") + end + + test "invalid CIDR returns nil" do + line = "not-a-cidr,12345,67890,,\n" + assert nil == Import.parse_block_line(line) + end + end + + describe "parse_location_line/1" do + test "parses a standard location row" do + line = "5332921,en,NA,North America,US,United States,CA,California,,,San Jose,,America/Los_Angeles,false" + result = Import.parse_location_line(line) + + assert result.geoname_id == 5_332_921 + assert result.country_code == "US" + assert result.country_name == "United States" + assert result.city_name == "San Jose" + assert result.subdivision_1_name == "California" + end + + test "empty city becomes nil" do + line = "5332921,en,NA,North America,US,United States,CA,California,,,,,,false" + result = Import.parse_location_line(line) + + assert result.city_name == nil + end + + test "missing country_code skips the row" do + line = "5332921,en,NA,,,United States,CA,California,,,San Jose,,tz,false" + assert nil == Import.parse_location_line(line) + end + + test "missing geoname_id skips the row" do + line = ",en,NA,North America,US,United States,CA,California,,,San Jose,,tz,false" + assert nil == Import.parse_location_line(line) + end + + test "malformed short line returns nil" do + assert nil == Import.parse_location_line("too,few,fields") + end + end + + describe "build_block_entry/3" do + test "nil geoname_id returns nil" do + assert nil == Import.build_block_entry("192.168.1.0/24", nil, 67_890) + end + + test "invalid CIDR returns nil" do + assert nil == Import.build_block_entry("garbage", 12_345, 67_890) + end + + test "valid inputs produce a complete entry" do + entry = Import.build_block_entry("10.0.0.0/8", 12_345, 67_890) + + assert entry.network == "10.0.0.0/8" + assert entry.geoname_id == 12_345 + assert entry.registered_country_geoname_id == 67_890 + assert entry.start_ip_int < entry.end_ip_int + end + end +end diff --git a/test/support/ping_stub.ex b/test/support/ping_stub.ex deleted file mode 100644 index eece12ff..00000000 --- a/test/support/ping_stub.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Towerops.Monitoring.PingStub do - @moduledoc """ - Stub implementation of PingBehaviour for testing. - Always returns successful ping responses. - """ - - @behaviour Towerops.Monitoring.PingBehaviour - - @impl true - def ping(_ip_address) do - {:ok, 10} - end - - @impl true - def ping(_ip_address, _timeout) do - {:ok, 10} - end -end diff --git a/test/support/snmp_kit_mock.ex b/test/support/snmp_kit_mock.ex deleted file mode 100644 index 334d66f2..00000000 --- a/test/support/snmp_kit_mock.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Towerops.Mocks.SnmpKitMock do - @moduledoc """ - Mock for SnmpKit to be used in tests. - """ - - def get(_target, _oid, _opts) do - {:ok, {:octet_string, "Mock Value"}} - end - - def walk(_target, _oid, _opts) do - # Return empty list to simulate no results - {:ok, []} - end - - def get_bulk(_target, _oid, _opts) do - {:ok, []} - end - - def get_multiple(_target, oids, _opts) do - # Return map of OID to mock typed values - result_map = - Map.new(oids, fn oid -> - {oid, {:octet_string, "Mock Value"}} - end) - - {:ok, result_map} - end -end diff --git a/test/towerops/accounts/hibp_test.exs b/test/towerops/accounts/hibp_test.exs index f06a57e1..42524896 100644 --- a/test/towerops/accounts/hibp_test.exs +++ b/test/towerops/accounts/hibp_test.exs @@ -39,4 +39,40 @@ defmodule Towerops.Accounts.HIBPTest do assert {:ok, 0} = HIBP.check_password(unique) end end + + describe "hash_password/1" do + test "SHA-1 uppercase hex for 'password'" do + assert "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" == HIBP.hash_password("password") + end + + test "different passwords produce different hashes" do + assert HIBP.hash_password("a") != HIBP.hash_password("b") + end + + test "empty string has known SHA-1 hash" do + assert "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709" == HIBP.hash_password("") + end + end + + describe "check_suffix/2" do + test "finds matching suffix and parses count" do + body = "ABCD:100\r\nEFGH:5\r\n" + assert {:ok, 100} == HIBP.check_suffix(body, "ABCD") + assert {:ok, 5} == HIBP.check_suffix(body, "EFGH") + end + + test "returns {:ok, 0} when no match" do + body = "FOUND:10\r\n" + assert {:ok, 0} == HIBP.check_suffix(body, "MISS") + end + + test "handles empty response" do + assert {:ok, 0} == HIBP.check_suffix("", "any") + end + + test "skips malformed lines" do + body = "GOODLINE:5\r\nbad_no_colon\r\nALSO:GOOD:7\r\n" + assert {:ok, 5} == HIBP.check_suffix(body, "GOODLINE") + end + end end diff --git a/test/towerops/agent/enum_types_test.exs b/test/towerops/agent/enum_types_test.exs new file mode 100644 index 00000000..4ee820a9 --- /dev/null +++ b/test/towerops/agent/enum_types_test.exs @@ -0,0 +1,76 @@ +defmodule Towerops.Agent.EnumTypesTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Agent.JobType + alias Towerops.Agent.QueryType + + describe "JobType.value/1" do + test "maps each known atom to expected integer" do + assert JobType.value(:DISCOVER) == 0 + assert JobType.value(:POLL) == 1 + assert JobType.value(:MIKROTIK) == 2 + assert JobType.value(:TEST_CREDENTIALS) == 3 + assert JobType.value(:PING) == 4 + assert JobType.value(:LLDP_TOPOLOGY) == 5 + end + + test "raises for unknown atom" do + assert_raise KeyError, fn -> JobType.value(:UNKNOWN) end + end + end + + describe "JobType.key/1" do + test "maps each known integer back to atom" do + assert JobType.key(0) == :DISCOVER + assert JobType.key(1) == :POLL + assert JobType.key(2) == :MIKROTIK + assert JobType.key(3) == :TEST_CREDENTIALS + assert JobType.key(4) == :PING + assert JobType.key(5) == :LLDP_TOPOLOGY + end + end + + describe "JobType roundtrip" do + property "value/1 and key/1 are inverses for all known atoms" do + check all( + atom <- + member_of([:DISCOVER, :POLL, :MIKROTIK, :TEST_CREDENTIALS, :PING, :LLDP_TOPOLOGY]) + ) do + assert atom |> JobType.value() |> JobType.key() == atom + end + end + + property "key/1 and value/1 are inverses for all known integers" do + check all(int <- 0..5 |> Enum.to_list() |> member_of()) do + assert int |> JobType.key() |> JobType.value() == int + end + end + end + + describe "QueryType.value/1" do + test "maps GET and WALK" do + assert QueryType.value(:GET) == 0 + assert QueryType.value(:WALK) == 1 + end + + test "raises for unknown atom" do + assert_raise KeyError, fn -> QueryType.value(:SET) end + end + end + + describe "QueryType.key/1" do + test "maps 0 and 1" do + assert QueryType.key(0) == :GET + assert QueryType.key(1) == :WALK + end + end + + describe "QueryType roundtrip" do + property "value and key are inverses" do + check all(atom <- member_of([:GET, :WALK])) do + assert atom |> QueryType.value() |> QueryType.key() == atom + end + end + end +end diff --git a/test/towerops/agent/interface_test.exs b/test/towerops/agent/interface_test.exs new file mode 100644 index 00000000..edb8dc4d --- /dev/null +++ b/test/towerops/agent/interface_test.exs @@ -0,0 +1,43 @@ +defmodule Towerops.Agent.InterfaceTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Agent.Interface + + describe "struct defaults" do + test "defaults to empty id and name, zero if_index" do + assert %Interface{id: "", if_index: 0, if_name: ""} = %Interface{} + end + + test "accepts explicit field values" do + iface = %Interface{id: "abc-uuid", if_index: 42, if_name: "ether1"} + assert iface.id == "abc-uuid" + assert iface.if_index == 42 + assert iface.if_name == "ether1" + end + end + + describe "encode/1" do + test "produces a binary" do + iface = %Interface{id: "abc", if_index: 1, if_name: "eth0"} + assert is_binary(Interface.encode(iface)) + end + + test "default struct encodes without error" do + assert is_binary(Interface.encode(%Interface{})) + end + end + + describe "property: encode/1 always returns binary" do + property "random interface structs encode cleanly" do + check all( + id <- string(:alphanumeric, max_length: 36), + if_index <- integer(0..2_147_483_647), + if_name <- string(:printable, max_length: 64) + ) do + iface = %Interface{id: id, if_index: if_index, if_name: if_name} + assert is_binary(Interface.encode(iface)) + end + end + end +end diff --git a/test/towerops/alerts/alert_storm_test.exs b/test/towerops/alerts/alert_storm_test.exs new file mode 100644 index 00000000..edc4f3b8 --- /dev/null +++ b/test/towerops/alerts/alert_storm_test.exs @@ -0,0 +1,93 @@ +defmodule Towerops.Alerts.AlertStormTest do + use Towerops.DataCase, async: true + + alias Towerops.Alerts.AlertStorm + + describe "changeset/2" do + test "valid with required fields" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + started_at: ~U[2026-01-15 10:00:00Z] + } + + changeset = AlertStorm.changeset(%AlertStorm{}, attrs) + + assert changeset.valid? + assert changeset.changes.organization_id == attrs.organization_id + assert changeset.changes.started_at == attrs.started_at + end + + test "valid with all fields" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + started_at: ~U[2026-01-15 10:00:00Z], + ended_at: ~U[2026-01-15 10:30:00Z], + alert_count: 42, + suppressed_count: 40, + summary_notification_sent: true + } + + changeset = AlertStorm.changeset(%AlertStorm{}, attrs) + + assert changeset.valid? + assert changeset.changes.alert_count == 42 + assert changeset.changes.suppressed_count == 40 + assert changeset.changes.summary_notification_sent == true + assert changeset.changes.ended_at == ~U[2026-01-15 10:30:00Z] + end + + test "invalid without organization_id" do + attrs = %{started_at: ~U[2026-01-15 10:00:00Z]} + + changeset = AlertStorm.changeset(%AlertStorm{}, attrs) + + refute changeset.valid? + assert %{organization_id: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid without started_at" do + attrs = %{organization_id: Ecto.UUID.generate()} + + changeset = AlertStorm.changeset(%AlertStorm{}, attrs) + + refute changeset.valid? + assert %{started_at: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid with both organization_id and started_at missing" do + changeset = AlertStorm.changeset(%AlertStorm{}, %{}) + + refute changeset.valid? + errors = errors_on(changeset) + assert errors[:organization_id] == ["can't be blank"] + assert errors[:started_at] == ["can't be blank"] + end + + test "ignores unknown fields" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + started_at: ~U[2026-01-15 10:00:00Z], + unknown_field: "ignored" + } + + changeset = AlertStorm.changeset(%AlertStorm{}, attrs) + + assert changeset.valid? + refute Map.has_key?(changeset.changes, :unknown_field) + end + end + + describe "schema defaults" do + test "alert_count defaults to 0" do + assert %AlertStorm{alert_count: 0} = %AlertStorm{} + end + + test "suppressed_count defaults to 0" do + assert %AlertStorm{suppressed_count: 0} = %AlertStorm{} + end + + test "summary_notification_sent defaults to false" do + assert %AlertStorm{summary_notification_sent: false} = %AlertStorm{} + end + end +end diff --git a/test/towerops/alerts/storm_detector_test.exs b/test/towerops/alerts/storm_detector_test.exs new file mode 100644 index 00000000..8c780e6e --- /dev/null +++ b/test/towerops/alerts/storm_detector_test.exs @@ -0,0 +1,68 @@ +defmodule Towerops.Alerts.StormDetectorTest do + @moduledoc """ + Tests for pure helpers. The buffered alert-creation paths require a running + GenServer and real alert/device fixtures — covered at the integration level. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Alerts.StormDetector + + defp queue_of(list), do: Enum.reduce(list, :queue.new(), fn x, q -> :queue.in(x, q) end) + + describe "prune_old_timestamps/2" do + test "removes timestamps older than cutoff" do + pruned = StormDetector.prune_old_timestamps(queue_of([100, 200, 300]), 250) + assert :queue.to_list(pruned) == [300] + end + + test "returns queue unchanged when all timestamps are recent" do + pruned = StormDetector.prune_old_timestamps(queue_of([500, 600]), 100) + assert :queue.to_list(pruned) == [500, 600] + end + + test "returns empty queue when all expire" do + pruned = StormDetector.prune_old_timestamps(queue_of([10, 20]), 1_000) + assert :queue.is_empty(pruned) + end + + test "empty queue stays empty" do + assert :queue.is_empty(StormDetector.prune_old_timestamps(:queue.new(), 100)) + end + + property "pruned queue never grows larger than input" do + check all( + items <- list_of(integer(0..1000), max_length: 30), + cutoff <- integer(0..1000) + ) do + q = queue_of(items) + pruned = StormDetector.prune_old_timestamps(q, cutoff) + assert :queue.len(pruned) <= :queue.len(q) + end + end + end + + describe "limit_queue_size/2" do + test "trims oldest items to fit max_size" do + trimmed = StormDetector.limit_queue_size(queue_of(Enum.to_list(1..10)), 3) + assert :queue.to_list(trimmed) == [8, 9, 10] + end + + test "returns queue unchanged when under limit" do + assert :queue.to_list(StormDetector.limit_queue_size(queue_of([1, 2]), 5)) == [1, 2] + end + + test "exact size is unchanged" do + assert :queue.to_list(StormDetector.limit_queue_size(queue_of([1, 2]), 2)) == [1, 2] + end + + property "result never exceeds max_size" do + check all( + items <- list_of(integer(), max_length: 50), + max <- integer(0..30) + ) do + assert :queue.len(StormDetector.limit_queue_size(queue_of(items), max)) <= max + end + end + end +end diff --git a/test/towerops/cn_maestro/client_test.exs b/test/towerops/cn_maestro/client_test.exs index 51b240ee..d30d647b 100644 --- a/test/towerops/cn_maestro/client_test.exs +++ b/test/towerops/cn_maestro/client_test.exs @@ -93,4 +93,167 @@ defmodule Towerops.CnMaestro.ClientTest do assert length(perf) == 1 end end + + describe "get_token/3 — error paths" do + test "unauthorized token response" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "invalid_client"}) + end) + + assert {:error, :unauthorized} = Client.get_token("http://test", "bad", "bad") + end + + test "other status returns {:token_failed, status}" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{}) + end) + + assert {:error, {:token_failed, 500}} = Client.get_token("http://test", "id", "secret") + end + + test "200 but missing access_token in JSON body" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"something" => "else"}) + end) + + assert {:error, {:token_failed, 200}} = Client.get_token("http://test", "id", "secret") + end + + test "accepts body as raw JSON string with access_token" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.send_resp(200, ~s({"access_token":"raw-token"})) + end) + + assert {:ok, "raw-token"} = Client.get_token("http://test", "id", "secret") + end + + test "raw string body without access_token returns :invalid_token_response" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.send_resp(200, ~s({"other":"thing"})) + end) + + assert {:error, :invalid_token_response} = Client.get_token("http://test", "id", "secret") + end + end + + describe "test_connection/3 — error paths" do + test "returns error when token fetch fails" do + Req.Test.stub(Client, fn conn -> + case conn.request_path do + "/api/v1/access/token" -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + + _ -> + Req.Test.json(conn, %{}) + end + end) + + assert {:error, :unauthorized} = Client.test_connection("http://test", "x", "y") + end + + test "returns {:unexpected_status, code} when device fetch fails" do + Req.Test.stub(Client, fn conn -> + case {conn.method, conn.request_path} do + {"POST", "/api/v1/access/token"} -> + Req.Test.json(conn, %{"access_token" => "tok"}) + + {"GET", "/api/v1/devices"} -> + conn |> Plug.Conn.put_status(503) |> Req.Test.json(%{}) + + _ -> + Plug.Conn.send_resp(conn, 404, "") + end + end) + + assert {:error, {:unexpected_status, 503}} = Client.test_connection("http://test", "x", "y") + end + end + + describe "list_devices/2 — error paths" do + test "returns unexpected_status on non-2xx" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{}) + end) + + assert {:error, {:unexpected_status, 500}} = Client.list_devices("http://test", "tok") + end + end + + describe "list_networks/2 — error paths" do + test "returns unexpected_status on non-2xx" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(502) |> Req.Test.json(%{}) + end) + + assert {:error, {:unexpected_status, 502}} = Client.list_networks("http://test", "tok") + end + end + + describe "get_device_statistics/3 — error paths" do + test "404 returns :not_found" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{}) + end) + + assert {:error, :not_found} = Client.get_device_statistics("http://test", "tok", "AA:BB") + end + + test "other status returns unexpected_status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{}) + end) + + assert {:error, {:unexpected_status, 500}} = + Client.get_device_statistics("http://test", "tok", "AA:BB") + end + end + + describe "get_device_performance/3 — error paths" do + test "404 returns :not_found" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{}) + end) + + assert {:error, :not_found} = Client.get_device_performance("http://test", "tok", "AA:BB") + end + + test "other status returns unexpected_status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{}) + end) + + assert {:error, {:unexpected_status, 500}} = + Client.get_device_performance("http://test", "tok", "AA:BB") + end + end + + describe "response body normalization (extract_data)" do + test "handles body with no data wrapper (direct list)" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, [%{"id" => 1}, %{"id" => 2}]) + end) + + assert {:ok, [%{"id" => 1}, %{"id" => 2}]} = Client.list_devices("http://test", "tok") + end + + test "handles non-list data wrapped in {data: map}" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => %{"id" => 1}}) + end) + + assert {:ok, [%{"id" => 1}]} = Client.list_devices("http://test", "tok") + end + + test "returns empty list for unknown body shape" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"unexpected" => "shape"}) + end) + + assert {:ok, []} = Client.list_devices("http://test", "tok") + end + end end diff --git a/test/towerops/cn_maestro/sync_test.exs b/test/towerops/cn_maestro/sync_test.exs new file mode 100644 index 00000000..3385e909 --- /dev/null +++ b/test/towerops/cn_maestro/sync_test.exs @@ -0,0 +1,56 @@ +defmodule Towerops.CnMaestro.SyncTest do + @moduledoc """ + Tests for process_network/3 — covers both the pure skip-dispatch and the + DB-backed upsert_site path. + """ + use Towerops.DataCase, async: true + + alias Towerops.CnMaestro.Sync + alias Towerops.Sites.Site + + describe "process_network/3 — skip paths (no DB)" do + test "skips network with nil name" do + state = {5, %{}} + assert state == Sync.process_network(%{"name" => nil}, Ecto.UUID.generate(), state) + end + + test "skips network with empty name" do + state = {0, %{}} + assert state == Sync.process_network(%{"name" => ""}, Ecto.UUID.generate(), state) + end + end + + describe "process_network/3 — DB upsert" do + setup do + user = Towerops.AccountsFixtures.user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "CN Org"}, user.id) + %{org: org} + end + + test "creates site and indexes by network id", %{org: org} do + {count, map} = + Sync.process_network( + %{"name" => "Region-A", "id" => "net-a"}, + org.id, + {0, %{}} + ) + + assert count == 1 + assert %Site{name: "Region-A"} = map["net-a"] + end + + test "uses name as key when id missing", %{org: org} do + {count, map} = + Sync.process_network(%{"name" => "Region-B"}, org.id, {0, %{}}) + + assert count == 1 + assert %Site{name: "Region-B"} = map["Region-B"] + end + + test "upsert returns the same site for duplicate name", %{org: org} do + {1, map1} = Sync.process_network(%{"name" => "Dup", "id" => "k1"}, org.id, {0, %{}}) + {_, map2} = Sync.process_network(%{"name" => "Dup", "id" => "k1"}, org.id, {0, %{}}) + assert map1["k1"].id == map2["k1"].id + end + end +end diff --git a/test/towerops/config_changes/correlator_test.exs b/test/towerops/config_changes/correlator_test.exs new file mode 100644 index 00000000..67725fa5 --- /dev/null +++ b/test/towerops/config_changes/correlator_test.exs @@ -0,0 +1,192 @@ +defmodule Towerops.ConfigChanges.CorrelatorTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.ConfigChanges.Correlator + + describe "safe_pct_change/2" do + test "normal percentage increase" do + assert 0.5 == Correlator.safe_pct_change(10.0, 15.0) + assert 1.0 == Correlator.safe_pct_change(5.0, 10.0) + end + + test "percentage decrease is negative" do + assert -0.5 == Correlator.safe_pct_change(10.0, 5.0) + end + + test "no change is 0.0" do + assert 0.0 == Correlator.safe_pct_change(10.0, 10.0) + end + + test "zero old value returns 0.0 (avoids division by zero)" do + assert 0.0 == Correlator.safe_pct_change(0.0, 100.0) + end + + test "negative old value returns 0.0" do + assert 0.0 == Correlator.safe_pct_change(-1.0, 100.0) + end + end + + describe "determine_severity_from_deltas/3" do + test "critical when latency > 50%" do + assert "critical" == Correlator.determine_severity_from_deltas(0.51, 0.0, 0.0) + assert "critical" == Correlator.determine_severity_from_deltas(1.0, 0.0, 0.0) + end + + test "critical when loss > 100%" do + assert "critical" == Correlator.determine_severity_from_deltas(0.0, 1.01, 0.0) + assert "critical" == Correlator.determine_severity_from_deltas(0.0, 5.0, 0.0) + end + + test "critical when throughput drops > 30%" do + assert "critical" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.31) + assert "critical" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.8) + end + + test "warning when latency in (20%, 50%]" do + assert "warning" == Correlator.determine_severity_from_deltas(0.21, 0.0, 0.0) + assert "warning" == Correlator.determine_severity_from_deltas(0.50, 0.0, 0.0) + end + + test "warning when loss in (50%, 100%]" do + assert "warning" == Correlator.determine_severity_from_deltas(0.0, 0.51, 0.0) + assert "warning" == Correlator.determine_severity_from_deltas(0.0, 1.0, 0.0) + end + + test "warning when throughput drops in (15%, 30%]" do + assert "warning" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.16) + assert "warning" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.30) + end + + test "info at and below warning thresholds" do + assert "info" == Correlator.determine_severity_from_deltas(0.20, 0.50, -0.15) + assert "info" == Correlator.determine_severity_from_deltas(0.0, 0.0, 0.0) + assert "info" == Correlator.determine_severity_from_deltas(-0.5, -0.5, 0.5) + end + + test "any one critical trigger bumps severity to critical" do + # Even if other metrics are fine + assert "critical" == Correlator.determine_severity_from_deltas(1.0, 0.0, 0.0) + assert "critical" == Correlator.determine_severity_from_deltas(0.0, 2.0, 0.0) + assert "critical" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.5) + end + end + + describe "maybe_add/3" do + test "appends on true" do + assert ["a", "b"] == Correlator.maybe_add(["a"], "b", true) + end + + test "does not append on false" do + assert ["a"] == Correlator.maybe_add(["a"], "b", false) + end + + test "starting from empty list" do + assert ["x"] == Correlator.maybe_add([], "x", true) + assert [] == Correlator.maybe_add([], "x", false) + end + end + + describe "to_float/1" do + test "nil becomes 0.0" do + assert 0.0 == Correlator.to_float(nil) + end + + test "float passes through" do + assert 3.14 == Correlator.to_float(3.14) + assert 0.0 == Correlator.to_float(0.0) + end + + test "integer converts to float" do + assert 42.0 == Correlator.to_float(42) + assert 0.0 == Correlator.to_float(0) + assert -7.0 == Correlator.to_float(-7) + end + end + + describe "build_description/2" do + test "includes sections when present" do + deltas = %{latency_delta: 0.15, loss_delta: 0.15, throughput_delta: -0.10} + event = %{sections_changed: ["firewall", "nat"]} + + desc = Correlator.build_description(deltas, event) + assert desc =~ "After config change (sections: firewall, nat)" + assert desc =~ "latency +15%" + assert desc =~ "loss +15%" + assert desc =~ "throughput -10%" + end + + test "skips sections when empty list" do + deltas = %{latency_delta: 0.15, loss_delta: 0.0, throughput_delta: 0.0} + event = %{sections_changed: []} + + desc = Correlator.build_description(deltas, event) + assert desc =~ "After config change: latency +15%" + refute desc =~ "(sections:" + end + + test "skips sections when nil" do + deltas = %{latency_delta: 0.15, loss_delta: 0.0, throughput_delta: 0.0} + event = %{sections_changed: nil} + + desc = Correlator.build_description(deltas, event) + refute desc =~ "(sections:" + end + + test "omits sub-threshold deltas" do + deltas = %{latency_delta: 0.05, loss_delta: 0.05, throughput_delta: -0.02} + event = %{sections_changed: []} + + desc = Correlator.build_description(deltas, event) + refute desc =~ "latency" + refute desc =~ "loss" + refute desc =~ "throughput" + assert desc =~ "After config change:" + end + end + + describe "property: safe_pct_change" do + property "positive old always produces finite ratio" do + check all( + old <- float(min: 0.01, max: 1_000.0), + new_val <- float(min: 0.0, max: 10_000.0) + ) do + result = Correlator.safe_pct_change(old, new_val) + assert is_float(result) + assert_in_delta result, (new_val - old) / old, 1.0e-9 + end + end + + property "zero or negative old always produces 0.0" do + check all( + old <- one_of([constant(0.0), float(min: -1000.0, max: 0.0)]), + new_val <- float() + ) do + assert 0.0 == Correlator.safe_pct_change(old, new_val) + end + end + end + + describe "property: determine_severity_from_deltas monotonicity" do + property "critical thresholds produce critical regardless of other metrics" do + check all( + latency <- float(min: 0.51, max: 10.0), + loss <- float(min: -1.0, max: 1.0), + throughput <- float(min: -1.0, max: 1.0) + ) do + assert "critical" == + Correlator.determine_severity_from_deltas(latency, loss, throughput) + end + end + + property "info when all metrics within safe range" do + check all( + latency <- float(min: -1.0, max: 0.20), + loss <- float(min: -1.0, max: 0.50), + throughput <- float(min: -0.15, max: 1.0) + ) do + assert "info" == Correlator.determine_severity_from_deltas(latency, loss, throughput) + end + end + end +end diff --git a/test/towerops/config_changes_test.exs b/test/towerops/config_changes_test.exs new file mode 100644 index 00000000..b1299e21 --- /dev/null +++ b/test/towerops/config_changes_test.exs @@ -0,0 +1,152 @@ +defmodule Towerops.ConfigChangesTest do + use ExUnit.Case, async: true + + alias Towerops.ConfigChanges + + describe "detect_changed_sections/1" do + test "empty diff returns no sections" do + assert [] == ConfigChanges.detect_changed_sections("") + end + + test "firewall changes detected" do + diff = """ + /ip firewall filter add chain=input action=drop + """ + + assert "firewall" in ConfigChanges.detect_changed_sections(diff) + end + + test "interface changes detected" do + diff = """ + +/interface ethernet set [ find default-name=ether1 ] mtu=1500 + """ + + assert "interface" in ConfigChanges.detect_changed_sections(diff) + end + + test "nat changes detected (matches firewall too since it's a subsection)" do + diff = """ + /ip firewall nat add chain=srcnat action=masquerade + """ + + sections = ConfigChanges.detect_changed_sections(diff) + assert "nat" in sections + assert "firewall" in sections + end + + test "multiple independent sections" do + diff = """ + /ip firewall filter add chain=input + /ip dns set servers=1.1.1.1 + +/interface bridge add name=bridge1 + """ + + sections = ConfigChanges.detect_changed_sections(diff) + assert "firewall" in sections + assert "dns" in sections + assert "bridge" in sections + assert "interface" in sections + end + + test "wireless interface changes" do + diff = """ + +/interface wireless set wlan1 ssid=new-ssid + """ + + sections = ConfigChanges.detect_changed_sections(diff) + assert "wireless" in sections + assert "interface" in sections + end + + test "vlan changes" do + diff = """ + +/interface vlan add name=vlan10 vlan-id=10 + """ + + sections = ConfigChanges.detect_changed_sections(diff) + assert "vlan" in sections + end + + test "routing changes (ospf, bgp, route)" do + for line <- ["/ip route add dst-address=", "/routing ospf", "/routing bgp", "/ip route rip"] do + assert "routing" in ConfigChanges.detect_changed_sections("#{line}\n") + end + end + + test "user, snmp, system, ppp, queue, dhcp, address sections" do + mappings = [ + {"/user add name=admin", "user"}, + {"/snmp set enabled=yes", "snmp"}, + {"/system identity set name=router1", "system"}, + {"/ppp profile add", "ppp"}, + {"/queue simple add", "queue"}, + {"/ip dhcp-server add", "dhcp"}, + {"/ip address add", "address"} + ] + + for {line, section} <- mappings do + sections = ConfigChanges.detect_changed_sections("#{line}\n") + assert section in sections, "expected #{section} in sections for line #{line}" + end + end + + test "returns sorted list" do + diff = """ + /ip dns set + /ip firewall filter + +/interface bridge + """ + + sections = ConfigChanges.detect_changed_sections(diff) + assert sections == Enum.sort(sections) + end + + test "unrelated diff yields empty list" do + diff = """ + # Just a comment + some unrelated text + """ + + assert [] == ConfigChanges.detect_changed_sections(diff) + end + + test "non-binary input returns []" do + assert [] == ConfigChanges.detect_changed_sections(nil) + assert [] == ConfigChanges.detect_changed_sections(42) + assert [] == ConfigChanges.detect_changed_sections(%{}) + end + + test "patterns are anchored at line start (^/.../ m)" do + # "/ip firewall" in the middle of a line should NOT match (^ anchors) + diff = "foo /ip firewall filter bar" + assert [] == ConfigChanges.detect_changed_sections(diff) + end + + test "unified-diff lines with + prefix are detected" do + diff = """ + --- before + +++ after + @@ -1,3 +1,4 @@ + +/ip firewall filter add chain=input + """ + + assert "firewall" in ConfigChanges.detect_changed_sections(diff) + end + + test "unified-diff lines with - prefix are detected" do + diff = """ + -/ip dns set servers=8.8.8.8 + """ + + assert "dns" in ConfigChanges.detect_changed_sections(diff) + end + + test "unified-diff context lines with space prefix are detected" do + diff = """ + /interface ethernet set ether1 mtu=1500 + """ + + assert "interface" in ConfigChanges.detect_changed_sections(diff) + end + end +end diff --git a/test/towerops/ecto_types/ip_address_properties_test.exs b/test/towerops/ecto_types/ip_address_properties_test.exs new file mode 100644 index 00000000..14baf8fd --- /dev/null +++ b/test/towerops/ecto_types/ip_address_properties_test.exs @@ -0,0 +1,84 @@ +defmodule Towerops.EctoTypes.IpAddressPropertiesTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.EctoTypes.IpAddress + + defp ipv4_string_gen do + gen all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + "#{a}.#{b}.#{c}.#{d}" + end + end + + describe "property: IPv4 casting" do + property "any valid IPv4 string casts to a struct with matching tuple" do + check all(ip_string <- ipv4_string_gen()) do + assert {:ok, %IpAddress{version: :ipv4, tuple: tuple, address: addr}} = + IpAddress.cast(ip_string) + + assert tuple_size(tuple) == 4 + assert addr == ip_string + end + end + + property "tuple octets match the string representation" do + check all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + {:ok, %IpAddress{tuple: {ta, tb, tc, td}}} = + IpAddress.cast("#{a}.#{b}.#{c}.#{d}") + + assert {ta, tb, tc, td} == {a, b, c, d} + end + end + + property "round-trip through dump/load preserves struct" do + check all(ip_string <- ipv4_string_gen()) do + {:ok, ip} = IpAddress.cast(ip_string) + {:ok, dumped} = IpAddress.dump(ip) + {:ok, loaded} = IpAddress.load(dumped) + + assert loaded.address == ip.address + assert loaded.version == ip.version + assert loaded.tuple == ip.tuple + end + end + + property "idempotent: casting an already-cast struct returns it unchanged" do + check all(ip_string <- ipv4_string_gen()) do + {:ok, ip} = IpAddress.cast(ip_string) + assert {:ok, ^ip} = IpAddress.cast(ip) + end + end + end + + describe "property: invalid input rejection" do + property "octets > 255 are rejected" do + check all( + a <- integer(256..1000), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + assert :error == IpAddress.cast("#{a}.#{b}.#{c}.#{d}") + end + end + + property "non-IP strings are rejected" do + check all( + garbage <- string(:alphanumeric, min_length: 1, max_length: 20), + not String.match?(garbage, ~r/^\d/) + ) do + assert :error == IpAddress.cast(garbage) + end + end + end +end diff --git a/test/towerops/ecto_types/mac_address_properties_test.exs b/test/towerops/ecto_types/mac_address_properties_test.exs new file mode 100644 index 00000000..890f620b --- /dev/null +++ b/test/towerops/ecto_types/mac_address_properties_test.exs @@ -0,0 +1,113 @@ +defmodule Towerops.EctoTypes.MacAddressPropertiesTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.EctoTypes.MacAddress + + defp mac_bytes_gen do + gen all(bytes <- list_of(integer(0..255), length: 6)) do + bytes + end + end + + defp to_colon_format(bytes) do + bytes |> Enum.map_join(":", fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) |> String.downcase() + end + + defp to_hyphen_format(bytes) do + bytes |> Enum.map_join("-", fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) |> String.downcase() + end + + defp to_cisco_format(bytes) do + bytes + |> Enum.map(fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) + |> Enum.chunk_every(2) + |> Enum.map_join(".", &Enum.join/1) + |> String.downcase() + end + + defp to_compact_format(bytes) do + bytes |> Enum.map_join("", fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) |> String.downcase() + end + + describe "property: all input formats normalize to the same canonical form" do + property "colon, hyphen, cisco, and compact formats produce identical structs" do + check all(bytes <- mac_bytes_gen()) do + colon = to_colon_format(bytes) + hyphen = to_hyphen_format(bytes) + cisco = to_cisco_format(bytes) + compact = to_compact_format(bytes) + + assert {:ok, m_colon} = MacAddress.cast(colon) + assert {:ok, m_hyphen} = MacAddress.cast(hyphen) + assert {:ok, m_cisco} = MacAddress.cast(cisco) + assert {:ok, m_compact} = MacAddress.cast(compact) + + assert m_colon.address == m_hyphen.address + assert m_hyphen.address == m_cisco.address + assert m_cisco.address == m_compact.address + end + end + + property "uppercase input is normalized to lowercase" do + check all(bytes <- mac_bytes_gen()) do + upper = bytes |> to_colon_format() |> String.upcase() + lower = to_colon_format(bytes) + + assert {:ok, m1} = MacAddress.cast(upper) + assert {:ok, m2} = MacAddress.cast(lower) + assert m1.address == m2.address + end + end + end + + describe "property: binary representation consistency" do + property "binary field is always 6 bytes when cast succeeds from string" do + check all(bytes <- mac_bytes_gen()) do + {:ok, mac} = MacAddress.cast(to_colon_format(bytes)) + assert byte_size(mac.binary) == 6 + end + end + + test "explicit non-printable binaries round-trip to colon format" do + cases = [ + {<<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>, "00:11:22:33:44:55"}, + {<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>, "aa:bb:cc:dd:ee:ff"}, + {<<0x01, 0x02, 0x03, 0x04, 0x05, 0x06>>, "01:02:03:04:05:06"} + ] + + for {binary, expected} <- cases do + assert {:ok, %MacAddress{address: ^expected, binary: ^binary}} = + MacAddress.cast(binary) + end + end + end + + describe "property: idempotence and round-trips" do + property "idempotent on already-cast struct" do + check all(bytes <- mac_bytes_gen()) do + {:ok, mac} = MacAddress.cast(to_colon_format(bytes)) + assert {:ok, ^mac} = MacAddress.cast(mac) + end + end + + property "dump/load roundtrip preserves the struct" do + check all(bytes <- mac_bytes_gen()) do + {:ok, mac} = MacAddress.cast(to_colon_format(bytes)) + {:ok, dumped} = MacAddress.dump(mac) + {:ok, loaded} = MacAddress.load(dumped) + + assert loaded.address == mac.address + assert loaded.binary == mac.binary + end + end + end + + describe "property: invalid inputs" do + property "random short strings are rejected" do + check all(garbage <- string(:alphanumeric, min_length: 1, max_length: 5)) do + assert :error == MacAddress.cast(garbage) + end + end + end +end diff --git a/test/towerops/geocoding_test.exs b/test/towerops/geocoding_test.exs new file mode 100644 index 00000000..c37547ec --- /dev/null +++ b/test/towerops/geocoding_test.exs @@ -0,0 +1,140 @@ +defmodule Towerops.GeocodingTest do + use ExUnit.Case, async: false + + alias Towerops.Geocoding + + setup do + original = Application.get_env(:towerops, :google_maps_api_key) + Application.put_env(:towerops, :google_maps_api_key, "test-key") + + on_exit(fn -> + if is_nil(original) do + Application.delete_env(:towerops, :google_maps_api_key) + else + Application.put_env(:towerops, :google_maps_api_key, original) + end + end) + + :ok + end + + describe "geocode/1" do + test "returns lat/lng/formatted_address on OK response" do + Req.Test.stub(Geocoding, fn conn -> + assert conn.query_string =~ "address=" + assert conn.query_string =~ "key=test-key" + + Req.Test.json(conn, %{ + "status" => "OK", + "results" => [ + %{ + "geometry" => %{"location" => %{"lat" => 40.7128, "lng" => -74.006}}, + "formatted_address" => "New York, NY, USA" + } + ] + }) + end) + + assert {:ok, result} = Geocoding.geocode("New York") + assert result.latitude == 40.7128 + assert result.longitude == -74.006 + assert result.formatted_address == "New York, NY, USA" + end + + test "returns error on ZERO_RESULTS" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{"status" => "ZERO_RESULTS", "results" => []}) + end) + + assert {:error, message} = Geocoding.geocode("nonexistent address") + assert message =~ "No results" + end + + test "returns error on OVER_QUERY_LIMIT" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{"status" => "OVER_QUERY_LIMIT"}) + end) + + assert {:error, message} = Geocoding.geocode("anywhere") + assert message =~ "quota" + end + + test "returns error on REQUEST_DENIED with message" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{ + "status" => "REQUEST_DENIED", + "error_message" => "API key invalid" + }) + end) + + assert {:error, message} = Geocoding.geocode("anywhere") + assert message =~ "denied" + assert message =~ "API key invalid" + end + + test "returns error on INVALID_REQUEST" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{"status" => "INVALID_REQUEST"}) + end) + + assert {:error, message} = Geocoding.geocode("") + assert message =~ "Invalid request" + end + + test "returns error on unknown status" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{"status" => "UNKNOWN_ERROR"}) + end) + + assert {:error, message} = Geocoding.geocode("anywhere") + assert message =~ "UNKNOWN_ERROR" + end + + test "returns error when OK but missing geometry" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{ + "status" => "OK", + "results" => [%{"formatted_address" => "Somewhere"}] + }) + end) + + assert {:error, message} = Geocoding.geocode("anywhere") + assert message =~ "Invalid response format" + end + + test "returns error for unexpected response body" do + Req.Test.stub(Geocoding, fn conn -> + Req.Test.json(conn, %{"unexpected" => "shape"}) + end) + + assert {:error, message} = Geocoding.geocode("anywhere") + assert message =~ "Unexpected response format" + end + + test "returns error on non-200 status" do + Req.Test.stub(Geocoding, fn conn -> + conn + |> Plug.Conn.put_status(500) + |> Req.Test.json(%{"error" => "server error"}) + end) + + assert {:error, message} = Geocoding.geocode("anywhere") + assert message =~ "Geocoding API error" + assert message =~ "500" + end + + test "returns :no_api_key error when key is missing" do + Application.delete_env(:towerops, :google_maps_api_key) + System.delete_env("GOOGLE_MAPS_API_KEY") + + assert {:error, :no_api_key} = Geocoding.geocode("anywhere") + end + + test "returns :no_api_key error when key is whitespace-only" do + Application.put_env(:towerops, :google_maps_api_key, " ") + System.delete_env("GOOGLE_MAPS_API_KEY") + + assert {:error, :no_api_key} = Geocoding.geocode("anywhere") + end + end +end diff --git a/test/towerops/maintenance_context_test.exs b/test/towerops/maintenance_context_test.exs new file mode 100644 index 00000000..7e53c04c --- /dev/null +++ b/test/towerops/maintenance_context_test.exs @@ -0,0 +1,275 @@ +defmodule Towerops.MaintenanceContextTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Maintenance + + setup do + user = user_fixture() + org = Towerops.OrganizationsFixtures.organization_fixture(user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Maint Site #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + name: "Maint Device", + ip_address: "10.99.0.1" + }) + + %{org: org, site: site, device: device, user: user} + end + + defp create_window!(attrs) do + user_id = Map.get(attrs, :created_by_id) || (attrs[:user] && attrs.user.id) + attrs = Map.delete(attrs, :user) + + attrs = + if user_id do + Map.put(attrs, :created_by_id, user_id) + else + Map.put_new(attrs, :created_by_id, user_fixture().id) + end + + {:ok, w} = + Maintenance.create_window( + Map.merge( + %{ + name: "Routine", + starts_at: DateTime.add(DateTime.utc_now(), -3600, :second), + ends_at: DateTime.add(DateTime.utc_now(), 3600, :second), + suppress_alerts: true + }, + attrs + ) + ) + + w + end + + describe "CRUD" do + test "create/update/delete/get cycle", %{org: org} do + w = + create_window!(%{ + organization_id: org.id, + name: "Planned" + }) + + got = Maintenance.get_window!(w.id) + assert got.name == "Planned" + + {:ok, updated} = Maintenance.update_window(got, %{name: "Renamed"}) + assert updated.name == "Renamed" + + assert {:ok, _} = Maintenance.delete_window(updated) + end + end + + describe "list_windows/2 filtering" do + setup %{org: org} do + now = DateTime.utc_now() + + active = + create_window!(%{ + organization_id: org.id, + name: "Active", + starts_at: DateTime.add(now, -3600, :second), + ends_at: DateTime.add(now, 3600, :second) + }) + + upcoming = + create_window!(%{ + organization_id: org.id, + name: "Upcoming", + starts_at: DateTime.add(now, 3600, :second), + ends_at: DateTime.add(now, 7200, :second) + }) + + past = + create_window!(%{ + organization_id: org.id, + name: "Past", + starts_at: DateTime.add(now, -7200, :second), + ends_at: DateTime.add(now, -3600, :second) + }) + + %{active: active, upcoming: upcoming, past: past} + end + + test "no filter returns all", %{org: org} do + results = Maintenance.list_windows(org.id) + assert length(results) == 3 + end + + test "filter: :active", %{org: org, active: active} do + [got] = Maintenance.list_windows(org.id, filter: :active) + assert got.id == active.id + end + + test "filter: :upcoming", %{org: org, upcoming: upcoming} do + [got] = Maintenance.list_windows(org.id, filter: :upcoming) + assert got.id == upcoming.id + end + + test "filter: :past", %{org: org, past: past} do + [got] = Maintenance.list_windows(org.id, filter: :past) + assert got.id == past.id + end + end + + describe "device_in_maintenance?/1" do + test "direct device window", %{org: org, device: device} do + create_window!(%{organization_id: org.id, device_id: device.id}) + assert Maintenance.device_in_maintenance?(device.id) + end + + test "site window applies", %{org: org, site: site, device: device} do + create_window!(%{organization_id: org.id, site_id: site.id}) + assert Maintenance.device_in_maintenance?(device.id) + end + + test "org-wide window applies", %{org: org, device: device} do + create_window!(%{organization_id: org.id}) + assert Maintenance.device_in_maintenance?(device.id) + end + + test "false with no active windows", %{device: device} do + refute Maintenance.device_in_maintenance?(device.id) + end + + test "past windows don't count", %{org: org, device: device} do + now = DateTime.utc_now() + + create_window!(%{ + organization_id: org.id, + device_id: device.id, + starts_at: DateTime.add(now, -7200, :second), + ends_at: DateTime.add(now, -3600, :second) + }) + + refute Maintenance.device_in_maintenance?(device.id) + end + + test "suppress_alerts: false doesn't count", %{org: org, device: device} do + create_window!(%{organization_id: org.id, device_id: device.id, suppress_alerts: false}) + refute Maintenance.device_in_maintenance?(device.id) + end + end + + describe "site_in_maintenance?/1" do + test "site-scoped window only", %{org: org, site: site} do + create_window!(%{organization_id: org.id, site_id: site.id}) + assert Maintenance.site_in_maintenance?(site.id) + end + + test "device-scoped window doesn't make site in maintenance", %{org: org, site: site, device: device} do + create_window!(%{organization_id: org.id, site_id: site.id, device_id: device.id}) + refute Maintenance.site_in_maintenance?(site.id) + end + + test "returns false with no matching windows", %{site: site} do + refute Maintenance.site_in_maintenance?(site.id) + end + end + + describe "active_windows_for_device/1" do + test "de-dupes across direct/site/org matches", %{org: org, site: site, device: device} do + create_window!(%{organization_id: org.id, device_id: device.id, name: "d"}) + create_window!(%{organization_id: org.id, site_id: site.id, name: "s"}) + create_window!(%{organization_id: org.id, name: "o"}) + + results = Maintenance.active_windows_for_device(device.id) + assert length(results) == 3 + assert length(Enum.uniq_by(results, & &1.id)) == length(results) + end + + test "returns empty list for device with no windows", %{device: device} do + assert [] == Maintenance.active_windows_for_device(device.id) + end + end + + describe "active_windows_for_org/1" do + test "returns active windows with preloads", %{org: org} do + create_window!(%{organization_id: org.id, name: "Active"}) + + past = DateTime.add(DateTime.utc_now(), -7200, :second) + + create_window!(%{ + organization_id: org.id, + name: "Past", + starts_at: past, + ends_at: DateTime.add(past, 3600, :second) + }) + + results = Maintenance.active_windows_for_org(org.id) + assert length(results) == 1 + assert Enum.all?(results, fn w -> w.name == "Active" end) + end + end + + describe "maintenance_badge/1" do + test "returns name for device in maintenance", %{org: org, device: device} do + create_window!(%{organization_id: org.id, device_id: device.id, name: "Badge"}) + assert "Badge" == Maintenance.maintenance_badge(device) + end + + test "returns nil for device not in maintenance", %{device: device} do + assert nil == Maintenance.maintenance_badge(device) + end + + test "returns name for site in maintenance", %{org: org, site: site} do + create_window!(%{organization_id: org.id, site_id: site.id, name: "SiteBadge"}) + assert "SiteBadge" == Maintenance.maintenance_badge(site) + end + + test "returns nil for site without maintenance", %{site: site} do + assert nil == Maintenance.maintenance_badge(site) + end + + test "returns nil for other types" do + assert nil == Maintenance.maintenance_badge(nil) + assert nil == Maintenance.maintenance_badge(%{}) + assert nil == Maintenance.maintenance_badge("foo") + end + end + + describe "devices_in_maintenance/1 batch" do + test "empty list returns empty MapSet" do + assert MapSet.new() == Maintenance.devices_in_maintenance([]) + end + + test "identifies direct + site + org scopes in single batch", %{org: org, site: site, device: device} do + # Create another device that's only org-covered + other_device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + name: "OtherDev", + ip_address: "10.99.0.2" + }) + + create_window!(%{organization_id: org.id, name: "org wide"}) + + result = Maintenance.devices_in_maintenance([device.id, other_device.id]) + assert MapSet.member?(result, device.id) + assert MapSet.member?(result, other_device.id) + end + + test "omits devices not in maintenance", %{org: org, site: site} do + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + ip_address: "10.88.0.1" + }) + + refute MapSet.member?(Maintenance.devices_in_maintenance([device.id]), device.id) + end + end +end diff --git a/test/towerops/monitoring/executors/dns_executor_test.exs b/test/towerops/monitoring/executors/dns_executor_test.exs index 04852984..73d9ab56 100644 --- a/test/towerops/monitoring/executors/dns_executor_test.exs +++ b/test/towerops/monitoring/executors/dns_executor_test.exs @@ -1,5 +1,6 @@ defmodule Towerops.Monitoring.Executors.DnsExecutorTest do use ExUnit.Case, async: true + use ExUnitProperties alias Towerops.Monitoring.Executors.DnsExecutor @@ -108,4 +109,94 @@ defmodule Towerops.Monitoring.Executors.DnsExecutorTest do assert output =~ "Resolved to:" end end + + describe "record_type_to_atom/1" do + test "maps all known types" do + assert :a == DnsExecutor.record_type_to_atom("A") + assert :aaaa == DnsExecutor.record_type_to_atom("AAAA") + assert :cname == DnsExecutor.record_type_to_atom("CNAME") + assert :mx == DnsExecutor.record_type_to_atom("MX") + assert :txt == DnsExecutor.record_type_to_atom("TXT") + assert :ns == DnsExecutor.record_type_to_atom("NS") + assert :ptr == DnsExecutor.record_type_to_atom("PTR") + end + + test "defaults to :a for unknown types" do + assert :a == DnsExecutor.record_type_to_atom("SOA") + assert :a == DnsExecutor.record_type_to_atom("srv") + assert :a == DnsExecutor.record_type_to_atom("") + assert :a == DnsExecutor.record_type_to_atom("anything") + end + + test "is case-sensitive (lowercase rejected)" do + # Whitelist only matches uppercase; caller should normalize. + assert :a == DnsExecutor.record_type_to_atom("aaaa") + assert :a == DnsExecutor.record_type_to_atom("cname") + end + end + + describe "parse_server/1" do + test "parses valid IPv4 into 4-tuple on port 53" do + assert {{1, 1, 1, 1}, 53} == DnsExecutor.parse_server("1.1.1.1") + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("8.8.8.8") + assert {{192, 168, 1, 1}, 53} == DnsExecutor.parse_server("192.168.1.1") + end + + test "boundary values 0 and 255 are valid" do + assert {{0, 0, 0, 0}, 53} == DnsExecutor.parse_server("0.0.0.0") + assert {{255, 255, 255, 255}, 53} == DnsExecutor.parse_server("255.255.255.255") + end + + test "falls back to 8.8.8.8 for out-of-range octets" do + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("256.1.1.1") + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.1.1.999") + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("-1.2.3.4") + end + + test "falls back for wrong number of octets" do + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3") + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3.4.5") + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("") + end + + test "falls back for non-numeric octets" do + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("a.b.c.d") + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3.abc") + end + + test "falls back for octets with trailing garbage" do + # Integer.parse returns a remainder of "x" which kills the match + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3.4x") + end + + test "falls back for non-binary input" do + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server(nil) + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server(42) + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server([]) + end + end + + describe "property: parse_server/1" do + property "valid IPv4 strings always yield matching 4-tuples" do + check all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + assert {{^a, ^b, ^c, ^d}, 53} = DnsExecutor.parse_server("#{a}.#{b}.#{c}.#{d}") + end + end + + property "IPv4 strings with any out-of-range octet fall back to 8.8.8.8" do + check all( + pos <- integer(0..3), + bad <- integer(256..999) + ) do + octets = List.replace_at([0, 0, 0, 0], pos, bad) + str = Enum.map_join(octets, ".", &Integer.to_string/1) + assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server(str) + end + end + end end diff --git a/test/towerops/monitoring/executors/http_executor_test.exs b/test/towerops/monitoring/executors/http_executor_test.exs index 487e4117..b3a772c0 100644 --- a/test/towerops/monitoring/executors/http_executor_test.exs +++ b/test/towerops/monitoring/executors/http_executor_test.exs @@ -1,5 +1,6 @@ defmodule Towerops.Monitoring.Executors.HttpExecutorTest do use ExUnit.Case, async: true + use ExUnitProperties alias Towerops.Monitoring.Executors.HttpExecutor @@ -145,4 +146,141 @@ defmodule Towerops.Monitoring.Executors.HttpExecutorTest do assert {:ok, _, _} = HttpExecutor.execute(config) end end + + describe "normalize_http_method/1" do + test "maps all known lowercase methods" do + assert :get == HttpExecutor.normalize_http_method("get") + assert :post == HttpExecutor.normalize_http_method("post") + assert :put == HttpExecutor.normalize_http_method("put") + assert :patch == HttpExecutor.normalize_http_method("patch") + assert :delete == HttpExecutor.normalize_http_method("delete") + assert :head == HttpExecutor.normalize_http_method("head") + assert :options == HttpExecutor.normalize_http_method("options") + end + + test "defaults to :get for unknown methods" do + assert :get == HttpExecutor.normalize_http_method("trace") + assert :get == HttpExecutor.normalize_http_method("unknown") + end + + test "defaults to :get for uppercase (caller should downcase first)" do + # Whitelist only matches lowercase — intentional to force caller normalization. + assert :get == HttpExecutor.normalize_http_method("GET") + assert :get == HttpExecutor.normalize_http_method("POST") + end + + test "defaults to :get for empty/junk input" do + assert :get == HttpExecutor.normalize_http_method("") + assert :get == HttpExecutor.normalize_http_method("injected; rm -rf /") + end + end + + describe "private_ip?/1" do + test "loopback 127/8" do + assert HttpExecutor.private_ip?({127, 0, 0, 1}) + assert HttpExecutor.private_ip?({127, 255, 255, 254}) + end + + test "private class A (10/8)" do + assert HttpExecutor.private_ip?({10, 0, 0, 1}) + assert HttpExecutor.private_ip?({10, 255, 255, 254}) + end + + test "private class B (172.16/12)" do + assert HttpExecutor.private_ip?({172, 16, 0, 1}) + assert HttpExecutor.private_ip?({172, 31, 255, 254}) + + refute HttpExecutor.private_ip?({172, 15, 0, 1}) + refute HttpExecutor.private_ip?({172, 32, 0, 1}) + end + + test "private class C (192.168/16)" do + assert HttpExecutor.private_ip?({192, 168, 0, 1}) + assert HttpExecutor.private_ip?({192, 168, 255, 254}) + end + + test "link-local / AWS metadata (169.254/16)" do + assert HttpExecutor.private_ip?({169, 254, 169, 254}) + end + + test "unspecified 0.0.0.0" do + assert HttpExecutor.private_ip?({0, 0, 0, 0}) + end + + test "public IPs are not private" do + refute HttpExecutor.private_ip?({1, 1, 1, 1}) + refute HttpExecutor.private_ip?({8, 8, 8, 8}) + refute HttpExecutor.private_ip?({93, 184, 216, 34}) + end + + test "non-tuples are not private" do + refute HttpExecutor.private_ip?(nil) + refute HttpExecutor.private_ip?("127.0.0.1") + refute HttpExecutor.private_ip?({10, 0, 0}) + refute HttpExecutor.private_ip?(%{}) + end + end + + describe "property: private_ip? coverage" do + property "every 10.x.x.x is private" do + check all( + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + assert HttpExecutor.private_ip?({10, b, c, d}) + end + end + + property "every 127.x.x.x is private" do + check all( + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + assert HttpExecutor.private_ip?({127, b, c, d}) + end + end + + property "192.168.x.x is private, neighbors are not" do + check all( + c <- integer(0..255), + d <- integer(0..255) + ) do + assert HttpExecutor.private_ip?({192, 168, c, d}) + end + end + + property "172.16-31.x.x is private, 172.0-15/172.32-255 are not" do + check all( + b <- integer(16..31), + c <- integer(0..255), + d <- integer(0..255) + ) do + assert HttpExecutor.private_ip?({172, b, c, d}) + end + end + + property "172.0..15.x.x is not private" do + check all( + b <- integer(0..15), + c <- integer(0..255), + d <- integer(0..255) + ) do + refute HttpExecutor.private_ip?({172, b, c, d}) + end + end + + property "random non-reserved public addresses are not private" do + check all( + a <- integer(11..126), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + # 11-126 excludes 10/8 (private) and 127/8 (loopback). + refute HttpExecutor.private_ip?({a, b, c, d}) + end + end + end end diff --git a/test/towerops/monitoring/executors/ping_executor_test.exs b/test/towerops/monitoring/executors/ping_executor_test.exs index 4d9a325b..f627cc97 100644 --- a/test/towerops/monitoring/executors/ping_executor_test.exs +++ b/test/towerops/monitoring/executors/ping_executor_test.exs @@ -1,5 +1,6 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do use ExUnit.Case, async: true + use ExUnitProperties alias Towerops.Monitoring.Executors.PingExecutor @@ -154,4 +155,165 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do assert {:error, _reason} = PingExecutor.parse_output("") end end + + describe "validate_host/1" do + test "accepts plain IPv4" do + assert :ok == PingExecutor.validate_host("192.168.1.1") + assert :ok == PingExecutor.validate_host("10.0.0.1") + end + + test "accepts IPv6" do + assert :ok == PingExecutor.validate_host("2001:db8::1") + assert :ok == PingExecutor.validate_host("fe80::1") + end + + test "accepts plain hostnames" do + assert :ok == PingExecutor.validate_host("example.com") + assert :ok == PingExecutor.validate_host("my-host") + assert :ok == PingExecutor.validate_host("host123.subdomain.example.org") + end + + test "rejects shell metacharacters" do + assert {:error, reason} = PingExecutor.validate_host("127.0.0.1; rm -rf /") + assert reason =~ "Invalid host" + end + + test "rejects pipe and redirect" do + assert {:error, _} = PingExecutor.validate_host("host | cat") + assert {:error, _} = PingExecutor.validate_host("host > /tmp/x") + end + + test "rejects backticks" do + assert {:error, _} = PingExecutor.validate_host("`whoami`") + end + + test "rejects dollar sign" do + assert {:error, _} = PingExecutor.validate_host("$(whoami)") + end + + test "rejects whitespace" do + assert {:error, _} = PingExecutor.validate_host("host with space") + end + + test "rejects embedded newline (shell-injection vector)" do + # Using ^/$ anchors instead of \A/\z would let this slip through because + # $ matches before a trailing \n in PCRE by default. + assert {:error, _} = PingExecutor.validate_host("host\ninjected") + assert {:error, _} = PingExecutor.validate_host("host\n") + assert {:error, _} = PingExecutor.validate_host("\nhost") + end + + test "rejects embedded carriage return and tab" do + assert {:error, _} = PingExecutor.validate_host("host\rinjected") + assert {:error, _} = PingExecutor.validate_host("host\t") + end + + test "rejects empty string" do + assert {:error, _} = PingExecutor.validate_host("") + end + + test "rejects non-string types" do + assert {:error, reason} = PingExecutor.validate_host(nil) + assert reason =~ "must be a string" + + assert {:error, _} = PingExecutor.validate_host(123) + assert {:error, _} = PingExecutor.validate_host(:atom) + assert {:error, _} = PingExecutor.validate_host([]) + end + end + + describe "property: validate_host/1" do + property "pure alphanumeric-dot-hyphen-colon strings are always accepted" do + check all( + host <- + string([?a..?z, ?A..?Z, ?0..?9, ?., ?-, ?:], min_length: 1, max_length: 30) + ) do + assert :ok == PingExecutor.validate_host(host) + end + end + + property "strings containing shell metacharacters are always rejected" do + check all( + prefix <- string(:alphanumeric, min_length: 1, max_length: 5), + bad <- member_of([";", "|", "&", "`", "$", "(", ")", " ", "\n"]), + suffix <- string(:alphanumeric, min_length: 0, max_length: 5) + ) do + assert {:error, _} = PingExecutor.validate_host(prefix <> bad <> suffix) + end + end + end + + describe "parse_packet_loss/1" do + test "extracts loss percentage from Linux ping output" do + output = "3 packets transmitted, 3 received, 0% packet loss" + assert {:ok, 0.0, "3 packets"} == PingExecutor.parse_packet_loss(output) + end + + test "extracts loss percentage from macOS ping output" do + output = "3 packets transmitted, 3 packets received, 0.0% packet loss" + assert {:ok, 0.0, "3 packets"} == PingExecutor.parse_packet_loss(output) + end + + test "handles partial loss" do + output = "10 packets transmitted, 7 received, 30% packet loss" + assert {:ok, 30.0, "10 packets"} == PingExecutor.parse_packet_loss(output) + end + + test "handles 100% loss with errors" do + output = "1 packets transmitted, 0 received, +1 errors, 100% packet loss" + assert {:ok, 100.0, "1 packets"} == PingExecutor.parse_packet_loss(output) + end + + test "error on unparseable output" do + assert {:error, _} = PingExecutor.parse_packet_loss("some garbage output") + end + end + + describe "parse_rtt/1" do + test "parses Linux rtt output" do + output = "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms" + assert {:ok, avg} = PingExecutor.parse_rtt(output) + assert_in_delta avg, 1.266, 0.001 + end + + test "parses macOS round-trip output" do + output = "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms" + assert {:ok, avg} = PingExecutor.parse_rtt(output) + assert_in_delta avg, 0.059, 0.001 + end + + test "error when RTT line absent (100% loss case)" do + output = "1 packets transmitted, 0 received, 100% packet loss" + assert {:error, _} = PingExecutor.parse_rtt(output) + end + end + + describe "format_loss/1" do + test "zero loss is '0%'" do + assert "0%" == PingExecutor.format_loss(0.0) + end + + test "partial loss shows 1 decimal place" do + assert "33.3%" == PingExecutor.format_loss(33.33) + assert "50.0%" == PingExecutor.format_loss(50.0) + end + + test "100% loss" do + assert "100.0%" == PingExecutor.format_loss(100.0) + end + end + + describe "build_args/3" do + test "contains -c count and host at tail on all platforms" do + args = PingExecutor.build_args("8.8.8.8", 3, 5000) + assert "-c" in args + assert "3" in args + assert List.last(args) == "8.8.8.8" + end + + test "timeout flag varies by OS (-W on darwin, -w elsewhere)" do + args = PingExecutor.build_args("host", 2, 4000) + assert "-W" in args or "-w" in args + end + end end diff --git a/test/towerops/monitoring/executors/snmp_executors_test.exs b/test/towerops/monitoring/executors/snmp_executors_test.exs new file mode 100644 index 00000000..b992527f --- /dev/null +++ b/test/towerops/monitoring/executors/snmp_executors_test.exs @@ -0,0 +1,249 @@ +defmodule Towerops.Monitoring.Executors.SnmpExecutorsTest do + @moduledoc """ + Unit tests for pure helper functions in the SNMP monitoring executors. + Tests cover OID construction, status mapping, credential selection, and + output formatting — none of which requires SNMP network access. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Monitoring.Executors.SnmpInterfaceExecutor + alias Towerops.Monitoring.Executors.SnmpProcessorExecutor + alias Towerops.Monitoring.Executors.SnmpStorageExecutor + + describe "SnmpProcessorExecutor.build_processor_oid/1" do + test "hr_processor prepends the host-resources OID" do + processor = %{processor_index: "1", processor_type: "hr_processor"} + assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.2.1.25.3.3.1.2.1" + end + + test "cisco_cpu uses the Cisco OID" do + processor = %{processor_index: "7", processor_type: "cisco_cpu"} + assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.4.1.9.9.109.1.1.1.1.5.7" + end + + test "unknown type falls back to host-resources" do + processor = %{processor_index: "3", processor_type: "unknown"} + assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.2.1.25.3.3.1.2.3" + end + + test "strips non-numeric characters from index" do + processor = %{processor_index: "cisco_42", processor_type: "hr_processor"} + assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.2.1.25.3.3.1.2.42" + end + end + + describe "SnmpProcessorExecutor.determine_status/1" do + test "< 80 is OK (0)" do + assert SnmpProcessorExecutor.determine_status(0.0) == 0 + assert SnmpProcessorExecutor.determine_status(50.0) == 0 + assert SnmpProcessorExecutor.determine_status(79.9) == 0 + end + + test "80-90 is WARNING (1)" do + assert SnmpProcessorExecutor.determine_status(80.0) == 1 + assert SnmpProcessorExecutor.determine_status(85.0) == 1 + assert SnmpProcessorExecutor.determine_status(89.9) == 1 + end + + test ">= 90 is CRITICAL (2)" do + assert SnmpProcessorExecutor.determine_status(90.0) == 2 + assert SnmpProcessorExecutor.determine_status(100.0) == 2 + end + + property "always returns 0, 1, or 2" do + check all(load <- float(min: 0.0, max: 200.0)) do + assert SnmpProcessorExecutor.determine_status(load) in [0, 1, 2] + end + end + + property "status is monotonically non-decreasing" do + check all( + base <- float(min: 0.0, max: 100.0), + delta <- float(min: 0.0, max: 50.0) + ) do + a = base + b = base + delta + + assert SnmpProcessorExecutor.determine_status(a) <= + SnmpProcessorExecutor.determine_status(b) + end + end + end + + describe "SnmpProcessorExecutor.format_output/2" do + test "uses description when present" do + output = SnmpProcessorExecutor.format_output(%{description: "CPU 0", processor_index: "0"}, 42.56) + assert output == "CPU 0: 42.6% load" + end + + test "falls back to Processor #{:index}" do + output = SnmpProcessorExecutor.format_output(%{description: nil, processor_index: "3"}, 10.0) + assert output == "Processor 3: 10.0% load" + end + end + + describe "SnmpProcessorExecutor.add_snmp_credentials/2" do + test "v2c sets community" do + result = SnmpProcessorExecutor.add_snmp_credentials([], %{snmp_version: "2c", snmp_community: "secret"}) + assert result[:community] == "secret" + end + + test "v2c defaults to public when community nil" do + result = SnmpProcessorExecutor.add_snmp_credentials([], %{snmp_version: "2c", snmp_community: nil}) + assert result[:community] == "public" + end + + test "v3 sets security options" do + device = %{ + snmp_version: "3", + snmpv3_username: "admin", + snmpv3_security_level: "authPriv", + snmpv3_auth_protocol: "SHA", + snmpv3_auth_password: "authpw", + snmpv3_priv_protocol: "AES", + snmpv3_priv_password: "privpw" + } + + result = SnmpProcessorExecutor.add_snmp_credentials([], device) + assert result[:security_name] == "admin" + assert result[:security_level] == "authPriv" + assert result[:auth_protocol] == "SHA" + assert result[:priv_protocol] == "AES" + end + end + + describe "SnmpStorageExecutor.ensure_integer/1" do + test "integers pass through" do + assert SnmpStorageExecutor.ensure_integer(42) == 42 + end + + test "floats round" do + assert SnmpStorageExecutor.ensure_integer(42.6) == 43 + assert SnmpStorageExecutor.ensure_integer(42.4) == 42 + end + + test "non-numbers become 0" do + assert SnmpStorageExecutor.ensure_integer(nil) == 0 + assert SnmpStorageExecutor.ensure_integer("abc") == 0 + assert SnmpStorageExecutor.ensure_integer(:atom) == 0 + end + end + + describe "SnmpStorageExecutor.calculate_usage_percent/1" do + test "size 0 returns 0.0 (no divide by zero)" do + assert SnmpStorageExecutor.calculate_usage_percent(%{size_units: 0, used_units: 10}) == 0.0 + end + + test "standard usage %" do + assert SnmpStorageExecutor.calculate_usage_percent(%{size_units: 100, used_units: 25}) == 25.0 + assert SnmpStorageExecutor.calculate_usage_percent(%{size_units: 200, used_units: 150}) == 75.0 + end + end + + describe "SnmpStorageExecutor.determine_status/1" do + test "< 85 is OK" do + assert SnmpStorageExecutor.determine_status(0) == 0 + assert SnmpStorageExecutor.determine_status(84.9) == 0 + end + + test "85-95 is WARNING" do + assert SnmpStorageExecutor.determine_status(85.0) == 1 + assert SnmpStorageExecutor.determine_status(94.9) == 1 + end + + test ">= 95 is CRITICAL" do + assert SnmpStorageExecutor.determine_status(95.0) == 2 + assert SnmpStorageExecutor.determine_status(100.0) == 2 + end + end + + describe "SnmpStorageExecutor.format_bytes/1" do + test "B/KB/MB/GB/TB transitions" do + assert SnmpStorageExecutor.format_bytes(512) == "512 B" + assert SnmpStorageExecutor.format_bytes(2048) == "2.0 KB" + assert SnmpStorageExecutor.format_bytes(5_000_000) == "4.77 MB" + assert SnmpStorageExecutor.format_bytes(2_000_000_000) == "1.86 GB" + assert SnmpStorageExecutor.format_bytes(1_500_000_000_000) == "1.36 TB" + end + end + + describe "SnmpStorageExecutor.format_output/3" do + test "uses description when present" do + storage = %{description: "/home", device_name: "box", storage_index: "1"} + data = %{allocation_units: 1024, size_units: 1000, used_units: 500} + + output = SnmpStorageExecutor.format_output(storage, 50.0, data) + assert String.contains?(output, "/home") + assert String.contains?(output, "50.0%") + end + + test "falls back to device_name then Storage #{:index}" do + storage = %{description: nil, device_name: "box", storage_index: "3"} + data = %{allocation_units: 1, size_units: 10, used_units: 5} + + output = SnmpStorageExecutor.format_output(storage, 50.0, data) + assert String.contains?(output, "box") + end + end + + describe "SnmpInterfaceExecutor.determine_status/2" do + test "up interface is OK" do + assert SnmpInterfaceExecutor.determine_status(1, %{if_admin_status: "up"}) == 0 + end + + test "admin-down interface that is down is OK (expected)" do + assert SnmpInterfaceExecutor.determine_status(2, %{if_admin_status: "down"}) == 0 + end + + test "down interface that should be up is CRITICAL" do + assert SnmpInterfaceExecutor.determine_status(2, %{if_admin_status: "up"}) == 2 + end + + test "testing / dormant is WARNING" do + assert SnmpInterfaceExecutor.determine_status(3, %{if_admin_status: "up"}) == 1 + assert SnmpInterfaceExecutor.determine_status(5, %{if_admin_status: "up"}) == 1 + end + + test "unknown states default to CRITICAL" do + for code <- [4, 6, 7, 99] do + assert SnmpInterfaceExecutor.determine_status(code, %{if_admin_status: "up"}) == 2 + end + end + end + + describe "SnmpInterfaceExecutor.oper_status_to_string/1" do + test "maps all known values" do + assert SnmpInterfaceExecutor.oper_status_to_string(1) == "Up" + assert SnmpInterfaceExecutor.oper_status_to_string(2) == "Down" + assert SnmpInterfaceExecutor.oper_status_to_string(3) == "Testing" + assert SnmpInterfaceExecutor.oper_status_to_string(4) == "Unknown" + assert SnmpInterfaceExecutor.oper_status_to_string(5) == "Dormant" + assert SnmpInterfaceExecutor.oper_status_to_string(6) == "Not Present" + assert SnmpInterfaceExecutor.oper_status_to_string(7) == "Lower Layer Down" + end + + test "unknown code is 'Unknown Status'" do + assert SnmpInterfaceExecutor.oper_status_to_string(99) == "Unknown Status" + assert SnmpInterfaceExecutor.oper_status_to_string(-1) == "Unknown Status" + end + end + + describe "SnmpInterfaceExecutor.format_output/2" do + test "prefers if_alias when present" do + interface = %{if_alias: "WAN", if_name: "eth0", if_descr: "Ethernet 0", if_index: 1} + assert "WAN: Up" == SnmpInterfaceExecutor.format_output(interface, 1) + end + + test "falls back through if_name, if_descr, then Interface #:index" do + assert "eth0: Down" == + SnmpInterfaceExecutor.format_output(%{if_alias: nil, if_name: "eth0", if_descr: nil, if_index: 0}, 2) + + assert "Ethernet 0: Down" == + SnmpInterfaceExecutor.format_output(%{if_alias: nil, if_name: nil, if_descr: "Ethernet 0", if_index: 0}, 2) + + assert "Interface 7: Down" == + SnmpInterfaceExecutor.format_output(%{if_alias: nil, if_name: nil, if_descr: nil, if_index: 7}, 2) + end + end +end diff --git a/test/towerops/monitoring/ping_test.exs b/test/towerops/monitoring/ping_test.exs index f6653c5a..0a5a6a54 100644 --- a/test/towerops/monitoring/ping_test.exs +++ b/test/towerops/monitoring/ping_test.exs @@ -164,4 +164,61 @@ defmodule Towerops.Monitoring.PingTest do end end end + + describe "validate_ip_address/1" do + test "empty string returns invalid_ip" do + assert {:error, :invalid_ip} == Ping.validate_ip_address("") + end + + test "valid IPv4 is :ok" do + assert :ok == Ping.validate_ip_address("192.168.1.1") + assert :ok == Ping.validate_ip_address("0.0.0.0") + assert :ok == Ping.validate_ip_address("255.255.255.255") + end + + test "valid IPv6 is :ok" do + assert :ok == Ping.validate_ip_address("::1") + assert :ok == Ping.validate_ip_address("2001:db8::1") + end + + test "invalid string returns invalid_ip" do + assert {:error, :invalid_ip} == Ping.validate_ip_address("not.an.ip") + assert {:error, :invalid_ip} == Ping.validate_ip_address("256.256.256.256") + end + end + + describe "parse_ping_output/1" do + test "parses macOS format" do + output = """ + PING 127.0.0.1 (127.0.0.1): 56 data bytes + round-trip min/avg/max/stddev = 0.050/0.123/0.200/0.075 ms + """ + + assert {:ok, 0.123} == Ping.parse_ping_output(output) + end + + test "parses Linux format" do + output = """ + --- 127.0.0.1 ping statistics --- + rtt min/avg/max/mdev = 0.010/0.025/0.050/0.020 ms + """ + + assert {:ok, 0.025} == Ping.parse_ping_output(output) + end + + test "parses simple time= format" do + output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.123 ms" + assert {:ok, 0.123} == Ping.parse_ping_output(output) + end + + test "parses time< fallback" do + output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time<0.5 ms" + assert {:ok, 0.5} == Ping.parse_ping_output(output) + end + + test "returns parse_error when no known pattern present" do + assert {:error, :parse_error} == Ping.parse_ping_output("total garbage") + assert {:error, :parse_error} == Ping.parse_ping_output("") + end + end end diff --git a/test/towerops/netbox/client_test.exs b/test/towerops/netbox/client_test.exs index 4240cf0f..458d7157 100644 --- a/test/towerops/netbox/client_test.exs +++ b/test/towerops/netbox/client_test.exs @@ -62,4 +62,187 @@ defmodule Towerops.NetBox.ClientTest do assert message =~ "missing or invalid" end end + + describe "normalize_url/1" do + test "accepts https URL" do + assert {:ok, "https://nb.example.com"} == Client.normalize_url("https://nb.example.com") + end + + test "accepts http URL" do + assert {:ok, "http://nb.local"} == Client.normalize_url("http://nb.local") + end + + test "trims whitespace and trailing slash" do + assert {:ok, "https://nb.example.com"} == + Client.normalize_url(" https://nb.example.com/ ") + end + + test "rejects non-http schemes" do + assert {:error, _} = Client.normalize_url("ftp://nb.example.com") + assert {:error, _} = Client.normalize_url("file:///etc/passwd") + end + + test "rejects non-binary input" do + assert {:error, _} = Client.normalize_url(42) + assert {:error, _} = Client.normalize_url(:atom) + end + end + + @url "https://nb.example.com" + @token "abc-token" + + describe "test_connection/2 via HTTP" do + test "returns netbox version on success" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/status/" + assert Plug.Conn.get_req_header(conn, "authorization") == ["Token #{@token}"] + + Req.Test.json(conn, %{"netbox-version" => "3.7.0"}) + end) + + assert {:ok, message} = Client.test_connection(@url, @token) + assert message =~ "3.7.0" + end + + test "returns generic success for django-only response" do + Req.Test.stub(Client, fn conn -> Req.Test.json(conn, %{"django-version" => "4.2"}) end) + assert {:ok, "Connected to NetBox"} = Client.test_connection(@url, @token) + end + + test "returns generic success for unknown body shape" do + Req.Test.stub(Client, fn conn -> Req.Test.json(conn, %{"something" => "else"}) end) + assert {:ok, "Connected to NetBox"} = Client.test_connection(@url, @token) + end + + test "401 -> authentication failed" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, msg} = Client.test_connection(@url, @token) + assert msg =~ "Authentication failed" + end + + test "403 -> authentication failed" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{}) + end) + + assert {:error, msg} = Client.test_connection(@url, @token) + assert msg =~ "Authentication failed" + end + + test "404 -> API not found at URL" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{}) + end) + + assert {:error, msg} = Client.test_connection(@url, @token) + assert msg =~ "not found at this URL" + end + + test "5xx -> generic connection failure" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{}) + end) + + assert {:error, msg} = Client.test_connection(@url, @token) + assert msg =~ "Connection failed" + assert msg =~ "500" + end + end + + describe "list endpoints via HTTP" do + test "list_devices/3 hits dcim/devices and passes params" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/dcim/devices/" + assert conn.query_string =~ "site=foo" + Req.Test.json(conn, %{"results" => [%{"id" => 1}]}) + end) + + assert {:ok, %{"results" => [%{"id" => 1}]}} = + Client.list_devices(@url, @token, %{"site" => "foo"}) + end + + test "list_sites/3 hits dcim/sites" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/dcim/sites/" + Req.Test.json(conn, %{"results" => []}) + end) + + assert {:ok, _} = Client.list_sites(@url, @token) + end + + test "list_ip_addresses/3 hits ipam endpoint" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/ipam/ip-addresses/" + Req.Test.json(conn, %{"results" => []}) + end) + + assert {:ok, _} = Client.list_ip_addresses(@url, @token) + end + + test "list_interfaces/3 hits interfaces endpoint" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/dcim/interfaces/" + Req.Test.json(conn, %{"results" => []}) + end) + + assert {:ok, _} = Client.list_interfaces(@url, @token) + end + end + + describe "upsert_device/3 via HTTP" do + test "POSTs attrs and returns 201 body" do + Req.Test.stub(Client, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/dcim/devices/" + + {:ok, body, conn} = Plug.Conn.read_body(conn) + payload = Jason.decode!(body) + assert payload["name"] == "dev-1" + + conn + |> Plug.Conn.put_status(201) + |> Req.Test.json(%{"id" => 42, "name" => "dev-1"}) + end) + + assert {:ok, %{"id" => 42}} = Client.upsert_device(@url, @token, %{"name" => "dev-1"}) + end + + test "accepts 200 status" do + Req.Test.stub(Client, fn conn -> Req.Test.json(conn, %{"id" => 42}) end) + assert {:ok, %{"id" => 42}} = Client.upsert_device(@url, @token, %{}) + end + + test "401 -> unauthorized" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Client.upsert_device(@url, @token, %{}) + end + + test "other status -> formatted HTTP error" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(422) |> Req.Test.json(%{"errors" => ["bad"]}) + end) + + assert {:error, msg} = Client.upsert_device(@url, @token, %{}) + assert msg =~ "HTTP 422" + end + end + + describe "upsert_site/3 via HTTP" do + test "POSTs site attrs" do + Req.Test.stub(Client, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/dcim/sites/" + + conn |> Plug.Conn.put_status(201) |> Req.Test.json(%{"id" => 99}) + end) + + assert {:ok, %{"id" => 99}} = Client.upsert_site(@url, @token, %{"name" => "site-1"}) + end + end end diff --git a/test/towerops/netbox/sync_test.exs b/test/towerops/netbox/sync_test.exs new file mode 100644 index 00000000..fbba4916 --- /dev/null +++ b/test/towerops/netbox/sync_test.exs @@ -0,0 +1,322 @@ +defmodule Towerops.NetBox.SyncTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.NetBox.Sync + + describe "extract_ip/1" do + test "returns nil for nil" do + assert nil == Sync.extract_ip(nil) + end + + test "strips CIDR suffix" do + assert "10.0.0.1" == Sync.extract_ip(%{"address" => "10.0.0.1/24"}) + assert "192.168.1.1" == Sync.extract_ip(%{"address" => "192.168.1.1/32"}) + end + + test "handles IP without CIDR" do + assert "10.0.0.1" == Sync.extract_ip(%{"address" => "10.0.0.1"}) + end + + test "handles IPv6 with CIDR" do + assert "2001:db8::1" == Sync.extract_ip(%{"address" => "2001:db8::1/64"}) + end + + test "returns nil for map without address key" do + assert nil == Sync.extract_ip(%{"other" => "value"}) + end + + test "returns nil for non-binary address" do + assert nil == Sync.extract_ip(%{"address" => 42}) + end + + test "returns nil for non-map input" do + assert nil == Sync.extract_ip("string") + assert nil == Sync.extract_ip(42) + assert nil == Sync.extract_ip([]) + end + end + + describe "map_status/1" do + test "maps known NetBox statuses to towerops statuses" do + assert "up" == Sync.map_status("active") + assert "down" == Sync.map_status("offline") + assert "pending" == Sync.map_status("planned") + assert "pending" == Sync.map_status("staged") + assert "down" == Sync.map_status("decommissioning") + end + + test "passes through unknown statuses unchanged" do + assert "custom" == Sync.map_status("custom") + assert "inventory" == Sync.map_status("inventory") + end + + test "preserves arbitrary strings" do + assert "anything" == Sync.map_status("anything") + end + end + + describe "parse_decimal/1" do + test "nil passes through" do + assert nil == Sync.parse_decimal(nil) + end + + test "float converts" do + assert Decimal.eq?(Decimal.from_float(1.5), Sync.parse_decimal(1.5)) + end + + test "integer converts" do + assert Decimal.eq?(Decimal.new(42), Sync.parse_decimal(42)) + end + + test "parseable binary converts" do + assert Decimal.eq?(Decimal.new("3.14"), Sync.parse_decimal("3.14")) + end + + test "unparseable binary returns nil" do + assert nil == Sync.parse_decimal("not a number") + assert nil == Sync.parse_decimal("") + end + + test "unknown types return nil" do + assert nil == Sync.parse_decimal(:atom) + assert nil == Sync.parse_decimal([]) + assert nil == Sync.parse_decimal(%{}) + end + end + + describe "build_device_params/1" do + test "returns empty map when no filters set" do + assert %{} == Sync.build_device_params(%{}) + end + + test "includes role when set" do + assert %{"role" => "core-router"} == Sync.build_device_params(%{"device_role_filter" => "core-router"}) + end + + test "includes site when set" do + assert %{"site" => "hq"} == Sync.build_device_params(%{"site_filter" => "hq"}) + end + + test "includes tag when set" do + assert %{"tag" => "production"} == Sync.build_device_params(%{"tag_filter" => "production"}) + end + + test "includes all when all set" do + params = + Sync.build_device_params(%{ + "device_role_filter" => "r", + "site_filter" => "s", + "tag_filter" => "t" + }) + + assert params == %{"role" => "r", "site" => "s", "tag" => "t"} + end + + test "skips empty strings" do + assert %{} == + Sync.build_device_params(%{ + "device_role_filter" => "", + "site_filter" => "", + "tag_filter" => "" + }) + end + + test "skips nil values" do + assert %{} == + Sync.build_device_params(%{ + "device_role_filter" => nil, + "site_filter" => nil + }) + end + + test "ignores unrelated credential keys" do + assert %{"role" => "r"} == + Sync.build_device_params(%{ + "device_role_filter" => "r", + "api_token" => "secret", + "url" => "https://..." + }) + end + end + + describe "resolve_site_id/2" do + test "returns nil for nil name" do + assert nil == Sync.resolve_site_id(nil, %{}) + end + + test "resolves via lowercased lookup" do + site_map = %{"hq" => %{id: "site-uuid-1"}} + assert "site-uuid-1" == Sync.resolve_site_id("HQ", site_map) + assert "site-uuid-1" == Sync.resolve_site_id("Hq", site_map) + assert "site-uuid-1" == Sync.resolve_site_id("hq", site_map) + end + + test "returns nil for unknown site name" do + assert nil == Sync.resolve_site_id("unknown", %{"hq" => %{id: "x"}}) + end + + test "returns nil when site map is empty" do + assert nil == Sync.resolve_site_id("any", %{}) + end + end + + describe "find_existing_device/4" do + test "finds by lowercased name" do + dev = %{id: "dev-1"} + assert dev == Sync.find_existing_device("Router-01", nil, %{"router-01" => dev}, %{}) + end + + test "falls back to IP when name misses" do + dev = %{id: "dev-2"} + + assert dev == + Sync.find_existing_device("unknown", "10.0.0.1", %{}, %{"10.0.0.1" => dev}) + end + + test "prefers name match over IP match" do + name_dev = %{id: "name-match"} + ip_dev = %{id: "ip-match"} + + assert name_dev == + Sync.find_existing_device( + "Router", + "10.0.0.1", + %{"router" => name_dev}, + %{"10.0.0.1" => ip_dev} + ) + end + + test "returns nil when both miss" do + assert nil == Sync.find_existing_device("x", "1.2.3.4", %{}, %{}) + end + + test "returns nil when name misses and IP is nil" do + assert nil == Sync.find_existing_device("x", nil, %{}, %{"1.2.3.4" => %{}}) + end + end + + describe "property: extract_ip round-trip with random CIDR suffixes" do + property "always returns the portion before the first /" do + check all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255), + cidr <- integer(0..32) + ) do + ip = "#{a}.#{b}.#{c}.#{d}" + assert ip == Sync.extract_ip(%{"address" => "#{ip}/#{cidr}"}) + end + end + end + + describe "property: parse_decimal/1 idempotence" do + property "non-negative floats round-trip via Decimal.to_string" do + check all(n <- float(min: 0.0, max: 1.0e6)) do + decimal = Sync.parse_decimal(n) + assert %Decimal{} = decimal + # Round-trip through string preserves value + round_tripped = decimal |> Decimal.to_string() |> Sync.parse_decimal() + assert Decimal.eq?(decimal, round_tripped) + end + end + + property "integers produce Decimal with matching to_integer value" do + check all(n <- integer(-1_000_000..1_000_000)) do + decimal = Sync.parse_decimal(n) + assert Decimal.to_integer(decimal) == n + end + end + end + + describe "property: map_status/1 is idempotent on towerops statuses" do + property "already-towerops statuses pass through (since they don't match)" do + check all(status <- member_of(["up", "down", "pending", "unknown", "custom"])) do + # map_status is a one-way mapping; applied twice to its output yields same value + once = Sync.map_status(status) + twice = Sync.map_status(once) + assert once == twice + end + end + end + + describe "site_id_from/1" do + test "nil returns nil" do + assert nil == Sync.site_id_from(nil) + end + + test "extracts :id field from a map" do + assert "uuid" == Sync.site_id_from(%{id: "uuid"}) + end + end + + describe "lookup_by_ip/2" do + test "nil ip returns nil regardless of map" do + assert nil == Sync.lookup_by_ip(nil, %{"1.2.3.4" => %{id: "x"}}) + end + + test "hits the map by ip" do + dev = %{id: "dev-1"} + assert dev == Sync.lookup_by_ip("10.0.0.1", %{"10.0.0.1" => dev}) + end + + test "miss returns nil" do + assert nil == Sync.lookup_by_ip("99.99.99.99", %{"10.0.0.1" => %{}}) + end + end + + describe "maybe_put/3" do + test "ignores nil values" do + assert %{} == Sync.maybe_put(%{}, "k", nil) + end + + test "ignores empty strings" do + assert %{} == Sync.maybe_put(%{}, "k", "") + end + + test "adds non-empty value" do + assert %{"k" => "v"} == Sync.maybe_put(%{}, "k", "v") + end + + test "preserves existing keys" do + assert %{"a" => 1, "b" => 2} == Sync.maybe_put(%{"a" => 1}, "b", 2) + end + end + + describe "build_site_params/1" do + test "empty map without tag_filter" do + assert %{} == Sync.build_site_params(%{}) + assert %{} == Sync.build_site_params(%{"tag_filter" => nil}) + assert %{} == Sync.build_site_params(%{"tag_filter" => ""}) + end + + test "returns tag param when present" do + assert %{"tag" => "production"} == Sync.build_site_params(%{"tag_filter" => "production"}) + end + + test "ignores unrelated credential keys" do + assert %{"tag" => "t"} == + Sync.build_site_params(%{"tag_filter" => "t", "url" => "u", "api_token" => "x"}) + end + end + + describe "humanize_sync_error/1" do + test "unauthorized message" do + assert Sync.humanize_sync_error(:unauthorized) =~ "Authentication failed" + end + + test "not_found message" do + assert Sync.humanize_sync_error(:not_found) =~ "API endpoint not found" + end + + test "SSL cert error mentions certificate" do + msg = Sync.humanize_sync_error("SSL: CA trust store missing") + assert msg =~ "SSL certificate" + end + + test "generic binary returns generic unexpected-error message" do + assert Sync.humanize_sync_error("some random failure") =~ "unexpected error" + end + end +end diff --git a/test/towerops/profiles/mib_cache_test.exs b/test/towerops/profiles/mib_cache_test.exs new file mode 100644 index 00000000..35fe0aa1 --- /dev/null +++ b/test/towerops/profiles/mib_cache_test.exs @@ -0,0 +1,54 @@ +defmodule Towerops.Profiles.MibCacheTest do + @moduledoc """ + Tests for the MibCache GenServer. In the test env, resolution is skipped via + compile-time check, so lookups either return cached hits or strip MODULE:: prefix. + """ + use ExUnit.Case, async: false + + alias Towerops.Profiles.MibCache + + setup do + # Ensure ETS table exists (GenServer is started via the application) + case Process.whereis(MibCache) do + nil -> + {:ok, _pid} = MibCache.start_link([]) + + _ -> + :ok + end + + :ok + end + + describe "lookup/1" do + test "returns cached oid when hit" do + :ets.insert(:mib_cache, {"ifDescr", "1.3.6.1.2.1.2.2.1.2"}) + assert "1.3.6.1.2.1.2.2.1.2" == MibCache.lookup("ifDescr") + end + + test "strips MODULE:: prefix on cache miss in test env" do + # Ensure key not cached + :ets.delete(:mib_cache, "IF-MIB::ifName") + assert "ifName" == MibCache.lookup("IF-MIB::ifName") + end + + test "returns original name when no module prefix and cache miss" do + :ets.delete(:mib_cache, "plainMib") + assert "plainMib" == MibCache.lookup("plainMib") + end + end + + describe "size/0" do + test "returns current ETS table size" do + :ets.insert(:mib_cache, {"size_test_#{System.unique_integer([:positive])}", "1.1.1"}) + assert is_integer(MibCache.size()) + assert MibCache.size() >= 1 + end + end + + describe "pre_resolve/1" do + test "returns :ok (fire-and-forget)" do + assert :ok == MibCache.pre_resolve(["ifName", "sysDescr"]) + end + end +end diff --git a/test/towerops/profiles/profile_watcher_test.exs b/test/towerops/profiles/profile_watcher_test.exs new file mode 100644 index 00000000..c5bcd3e3 --- /dev/null +++ b/test/towerops/profiles/profile_watcher_test.exs @@ -0,0 +1,53 @@ +defmodule Towerops.Profiles.ProfileWatcherTest do + use ExUnit.Case, async: true + + alias Towerops.Profiles.ProfileWatcher + + describe "yaml_file?/1" do + test "matches .yaml extension" do + assert ProfileWatcher.yaml_file?("/tmp/x/foo.yaml") + end + + test "matches .yml extension" do + assert ProfileWatcher.yaml_file?("/tmp/x/foo.yml") + end + + test "rejects unrelated extensions" do + refute ProfileWatcher.yaml_file?("/tmp/x/foo.json") + refute ProfileWatcher.yaml_file?("/tmp/x/foo") + refute ProfileWatcher.yaml_file?("/tmp/x/foo.exs") + end + end + + describe "should_reload?/1" do + test "reloads on :created" do + assert ProfileWatcher.should_reload?([:created]) + end + + test "reloads on :modified" do + assert ProfileWatcher.should_reload?([:modified]) + end + + test "reloads on :removed" do + assert ProfileWatcher.should_reload?([:removed]) + end + + test "reloads on :renamed" do + assert ProfileWatcher.should_reload?([:renamed]) + end + + test "reloads if any of several events matches" do + assert ProfileWatcher.should_reload?([:undefined, :modified]) + end + + test "does not reload on unrelated events" do + refute ProfileWatcher.should_reload?([:undefined]) + refute ProfileWatcher.should_reload?([]) + end + + test "non-list input returns false" do + refute ProfileWatcher.should_reload?(nil) + refute ProfileWatcher.should_reload?(:created) + end + end +end diff --git a/test/towerops/reports/notifier_test.exs b/test/towerops/reports/notifier_test.exs new file mode 100644 index 00000000..9bc1a090 --- /dev/null +++ b/test/towerops/reports/notifier_test.exs @@ -0,0 +1,107 @@ +defmodule Towerops.Reports.NotifierTest do + use ExUnit.Case, async: true + + import Swoosh.TestAssertions + + alias Towerops.Reports.Notifier + alias Towerops.Reports.Report + + describe "deliver_report/2" do + test "sends email with csv attachment to single recipient" do + report = %Report{ + id: Ecto.UUID.generate(), + name: "Monthly Uptime", + report_type: "uptime_summary", + recipients: ["ops@example.com"] + } + + assert :ok = Notifier.deliver_report(report, "col1,col2\nfoo,bar\n") + + assert_email_sent(fn email -> + assert email.to == [{"", "ops@example.com"}] + assert email.subject =~ "Uptime Summary" + assert email.subject =~ "Monthly Uptime" + assert email.text_body =~ "Monthly Uptime" + + assert [attachment] = email.attachments + assert attachment.content_type == "text/csv" + assert attachment.filename =~ "uptime_summary_" + assert attachment.filename =~ ".csv" + end) + end + + test "sends email to all recipients" do + report = %Report{ + id: Ecto.UUID.generate(), + name: "Alert History", + report_type: "alert_history", + recipients: ["a@example.com", "b@example.com", "c@example.com"] + } + + assert :ok = Notifier.deliver_report(report, "data\n") + + for recipient <- report.recipients do + assert_email_sent(fn email -> + assert email.to == [{"", recipient}] + end) + end + end + + test "humanizes known report types in subject" do + cases = [ + {"uptime_summary", "Uptime Summary"}, + {"alert_history", "Alert History"}, + {"capacity_trends", "Capacity Trends"}, + {"rf_link_health", "RF Link Health"} + ] + + for {type, expected} <- cases do + report = %Report{ + id: Ecto.UUID.generate(), + name: "Test", + report_type: type, + recipients: ["test@example.com"] + } + + assert :ok = Notifier.deliver_report(report, "data") + + assert_email_sent(fn email -> + assert email.subject =~ expected + end) + end + end + + test "passes through unknown report types verbatim" do + report = %Report{ + id: Ecto.UUID.generate(), + name: "Custom", + report_type: "custom_thing", + recipients: ["test@example.com"] + } + + assert :ok = Notifier.deliver_report(report, "data") + + assert_email_sent(fn email -> + assert email.subject =~ "custom_thing" + end) + end + + test "filename includes today's date in ISO8601 format" do + report = %Report{ + id: Ecto.UUID.generate(), + name: "Test", + report_type: "uptime_summary", + recipients: ["test@example.com"] + } + + today = Date.to_iso8601(Date.utc_today()) + + assert :ok = Notifier.deliver_report(report, "data") + + assert_email_sent(fn email -> + [attachment] = email.attachments + assert attachment.filename == "uptime_summary_#{today}.csv" + end) + end + end +end diff --git a/test/towerops/reports_test.exs b/test/towerops/reports_test.exs index 8bda3464..e8a0e73c 100644 --- a/test/towerops/reports_test.exs +++ b/test/towerops/reports_test.exs @@ -130,6 +130,108 @@ defmodule Towerops.ReportsTest do report = %Report{schedule: %{"type" => "weekly"}, last_run_at: nil} assert Reports.due?(report) end + + test "weekly report last run over 7 days ago is due" do + old = DateTime.add(DateTime.utc_now(), -604_801, :second) + report = %Report{schedule: %{"type" => "weekly"}, last_run_at: old} + assert Reports.due?(report) + end + + test "weekly report last run under 7 days ago is not due" do + recent = DateTime.add(DateTime.utc_now(), -600_000, :second) + report = %Report{schedule: %{"type" => "weekly"}, last_run_at: recent} + refute Reports.due?(report) + end + + test "monthly report never run is due" do + report = %Report{schedule: %{"type" => "monthly"}, last_run_at: nil} + assert Reports.due?(report) + end + + test "monthly report last run over 30 days ago is due" do + old = DateTime.add(DateTime.utc_now(), -2_592_001, :second) + report = %Report{schedule: %{"type" => "monthly"}, last_run_at: old} + assert Reports.due?(report) + end + + test "monthly report last run under 30 days ago is not due" do + recent = DateTime.add(DateTime.utc_now(), -2_000_000, :second) + report = %Report{schedule: %{"type" => "monthly"}, last_run_at: recent} + refute Reports.due?(report) + end + + test "unknown schedule type is not due" do + report = %Report{schedule: %{"type" => "hourly"}} + refute Reports.due?(report) + end + + test "empty schedule map is not due" do + report = %Report{schedule: %{}} + refute Reports.due?(report) + end + + test "nil schedule is not due" do + report = %Report{schedule: nil} + refute Reports.due?(report) + end + end + + describe "csv_escape/1" do + test "nil becomes empty string" do + assert "" == Reports.csv_escape(nil) + end + + test "plain strings pass through unchanged" do + assert "hello" == Reports.csv_escape("hello") + assert "no escaping needed" == Reports.csv_escape("no escaping needed") + end + + test "strings with commas get quoted" do + assert ~s("a, b, c") == Reports.csv_escape("a, b, c") + end + + test "strings with quotes get quoted and quotes doubled" do + assert ~s("she said ""hi""") == Reports.csv_escape(~s(she said "hi")) + end + + test "strings with newlines get quoted" do + assert ~s("line1\nline2") == Reports.csv_escape("line1\nline2") + end + + test "integers and other types coerced to string" do + assert "42" == Reports.csv_escape(42) + assert "3.14" == Reports.csv_escape(3.14) + assert "true" == Reports.csv_escape(true) + assert "foo" == Reports.csv_escape(:foo) + end + end + + describe "parse_time_range/1" do + test "defaults to 7 days when days is missing" do + now = DateTime.utc_now() + range = Reports.parse_time_range(%{}) + + # `to` should be within 1 second of now + assert DateTime.diff(range.to, now, :second) in -1..1 + # `from` should be ~7 days before `to` + diff_days = DateTime.diff(range.to, range.from, :day) + assert diff_days == 7 + end + + test "honours explicit days" do + range = Reports.parse_time_range(%{"days" => 30}) + assert DateTime.diff(range.to, range.from, :day) == 30 + end + + test "range.from is before range.to" do + range = Reports.parse_time_range(%{"days" => 1}) + assert DateTime.before?(range.from, range.to) + end + + test "zero days makes range.from == range.to (same timestamp)" do + range = Reports.parse_time_range(%{"days" => 0}) + assert range.from == range.to + end end describe "mark_run/2" do @@ -152,8 +254,128 @@ defmodule Towerops.ReportsTest do end end + describe "generate_csv/1" do + test "uptime_summary produces header + rows from devices" do + %{org: org, site: site} = setup_org_and_site() + + {:ok, _d1} = + Towerops.Devices.create_device( + %{ + name: "Router-1", + ip_address: "10.0.0.1", + organization_id: org.id, + site_id: site.id, + monitoring_enabled: true + }, + bypass_limits: true + ) + + {:ok, report} = + Reports.create_report(%{ + name: "Uptime", + report_type: "uptime_summary", + schedule: %{"type" => "daily"}, + recipients: ["a@b.com"], + organization_id: org.id + }) + + assert {:ok, csv} = Reports.generate_csv(report) + assert String.contains?(csv, "Device,IP Address,Status,Last Seen") + assert String.contains?(csv, "Router-1") + assert String.contains?(csv, "monitored") + end + + test "uptime_summary with unmonitored device marks as unmonitored" do + %{org: org, site: site} = setup_org_and_site() + + {:ok, _d} = + Towerops.Devices.create_device( + %{ + name: "Off", + ip_address: "10.0.0.2", + organization_id: org.id, + site_id: site.id, + monitoring_enabled: false + }, + bypass_limits: true + ) + + {:ok, report} = + Reports.create_report(%{ + name: "Uptime", + report_type: "uptime_summary", + schedule: %{"type" => "daily"}, + recipients: ["a@b.com"], + organization_id: org.id + }) + + {:ok, csv} = Reports.generate_csv(report) + assert String.contains?(csv, "unmonitored") + end + + test "alert_history produces CSV with no alerts" do + %{org: org} = setup_org_and_site() + + {:ok, report} = + Reports.create_report(%{ + name: "Alerts", + report_type: "alert_history", + schedule: %{"type" => "daily"}, + recipients: ["a@b.com"], + organization_id: org.id + }) + + assert {:ok, csv} = Reports.generate_csv(report) + assert String.contains?(csv, "Alert,Severity,Status,Device,Created At,Resolved At") + end + + test "capacity_trends returns header even for empty data" do + %{org: org} = setup_org_and_site() + + {:ok, report} = + Reports.create_report(%{ + name: "Cap", + report_type: "capacity_trends", + schedule: %{"type" => "weekly"}, + recipients: ["a@b.com"], + organization_id: org.id + }) + + assert {:ok, csv} = Reports.generate_csv(report) + assert String.contains?(csv, "Site,Total Capacity (bps),Throughput (bps),Utilization %") + end + + test "rf_link_health returns header with empty rf links" do + %{org: org} = setup_org_and_site() + + {:ok, report} = + Reports.create_report(%{ + name: "RF", + report_type: "rf_link_health", + schedule: %{"type" => "weekly"}, + recipients: ["a@b.com"], + organization_id: org.id + }) + + assert {:ok, csv} = Reports.generate_csv(report) + assert String.contains?(csv, "Client,MAC,Signal (dBm),SNR (dB),TX Rate,RX Rate,Health,Last Seen") + end + end + # -- Helpers -- + defp setup_org_and_site do + org = insert_org() + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + %{org: org, site: site} + end + defp insert_org do {:ok, org} = %Organization{} diff --git a/test/towerops/snmp/wireless_client_discovery/parser_test.exs b/test/towerops/snmp/wireless_client_discovery/parser_test.exs new file mode 100644 index 00000000..0cc41e1c --- /dev/null +++ b/test/towerops/snmp/wireless_client_discovery/parser_test.exs @@ -0,0 +1,331 @@ +defmodule Towerops.Snmp.WirelessClientDiscovery.ParserTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Snmp.WirelessClientDiscovery.Parser + + describe "detect_vendor_profile/1" do + test "Cambium via sys_object_id enterprise prefix" do + assert "epmp" == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.17713.21.1.2.1", + manufacturer: nil + }) + end + + test "Cambium via manufacturer substring (case-insensitive)" do + assert "epmp" == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.9999", + manufacturer: "Cambium Networks" + }) + end + + test "Ubiquiti via sys_object_id" do + assert "airos" == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.41112.1.4", + manufacturer: nil + }) + end + + test "Ubiquiti via manufacturer substring" do + assert "airos" == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.9999", + manufacturer: "Ubiquiti Inc" + }) + end + + test "MikroTik via sys_object_id" do + assert "routeros" == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.14988.1.1", + manufacturer: nil + }) + end + + test "MikroTik via manufacturer substring" do + assert "routeros" == + Parser.detect_vendor_profile(%{sys_object_id: "", manufacturer: "MikroTik"}) + end + + test "detection order is cambium → ubiquiti → mikrotik" do + # If multiple patterns match, first in chain wins + assert "epmp" == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.17713.x", + manufacturer: "Ubiquiti MikroTik" + }) + end + + test "unknown vendor returns nil" do + assert nil == + Parser.detect_vendor_profile(%{ + sys_object_id: "1.3.6.1.4.1.99999", + manufacturer: "Unknown Vendor" + }) + end + + test "nil/missing fields default to empty" do + assert nil == Parser.detect_vendor_profile(%{sys_object_id: nil, manufacturer: nil}) + assert nil == Parser.detect_vendor_profile(%{}) + end + + test "non-map input returns nil" do + assert nil == Parser.detect_vendor_profile(nil) + assert nil == Parser.detect_vendor_profile("not a map") + end + end + + describe "get_indexed_value/3" do + test "fetches value from map" do + walk = %{"1.2.3.4.10" => "hello", "1.2.3.4.20" => 42} + assert "hello" == Parser.get_indexed_value(walk, "1.2.3.4", "10") + assert 42 == Parser.get_indexed_value(walk, "1.2.3.4", "20") + end + + test "returns nil for missing key" do + assert nil == Parser.get_indexed_value(%{}, "1.2.3.4", "99") + end + + test "non-map walk returns nil" do + assert nil == Parser.get_indexed_value(nil, "a", "b") + assert nil == Parser.get_indexed_value([], "a", "b") + end + end + + describe "get_indexed_int/3" do + test "returns integer when value is integer" do + walk = %{"a.1" => 42} + assert 42 == Parser.get_indexed_int(walk, "a", "1") + end + + test "returns nil for non-integer value" do + walk = %{"a.1" => "42", "a.2" => 3.14} + assert nil == Parser.get_indexed_int(walk, "a", "1") + assert nil == Parser.get_indexed_int(walk, "a", "2") + end + + test "returns nil for missing key" do + assert nil == Parser.get_indexed_int(%{}, "a", "1") + end + end + + describe "get_indexed_string/3" do + test "returns string when value is non-empty binary" do + walk = %{"a.1" => "hello"} + assert "hello" == Parser.get_indexed_string(walk, "a", "1") + end + + test "returns nil for empty string" do + walk = %{"a.1" => ""} + assert nil == Parser.get_indexed_string(walk, "a", "1") + end + + test "returns nil for non-binary value" do + walk = %{"a.1" => 42} + assert nil == Parser.get_indexed_string(walk, "a", "1") + end + + test "returns nil for missing key" do + assert nil == Parser.get_indexed_string(%{}, "a", "1") + end + end + + describe "format_mac/1" do + test "6-byte binary to lowercase colon-separated" do + assert "00:11:22:33:44:55" == + Parser.format_mac(<<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>) + + assert "aa:bb:cc:dd:ee:ff" == + Parser.format_mac(<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>) + end + + test "separator strings normalize to colon lowercase" do + assert "aa:bb:cc:dd:ee:01" == Parser.format_mac("AA:BB:CC:DD:EE:01") + assert "aa:bb:cc:dd:ee:01" == Parser.format_mac("aa-bb-cc-dd-ee-01") + assert "aa:bb:cc:dd:ee:01" == Parser.format_mac("AA-BB-CC-DD-EE-01") + end + + test "padded short hex groups normalize correctly" do + # "0:1:2:3:4:5" → "0:1:2:3:4:5" (not padded, stays as is after hex extraction) + # Actually cleaning `0:1:2:3:4:5` gives `012345` (6 hex chars, not 12), so + # falls back to bytes_to_mac which treats the original string as bytes. + result = Parser.format_mac("0:1:2:3:4:5") + assert is_binary(result) + end + + test "non-MAC binaries fall through to byte format" do + # 3-byte binary — not 6 bytes, no separators → goes to bytes_to_mac + result = Parser.format_mac(<<1, 2, 3>>) + assert result == "01:02:03" + end + + test "non-binary input returns nil" do + assert nil == Parser.format_mac(nil) + assert nil == Parser.format_mac(42) + assert nil == Parser.format_mac(:atom) + assert nil == Parser.format_mac([]) + end + end + + describe "format_ip/1" do + test "4-byte binary to dotted quad" do + assert "192.168.1.1" == Parser.format_ip(<<192, 168, 1, 1>>) + assert "10.0.0.1" == Parser.format_ip(<<10, 0, 0, 1>>) + assert "0.0.0.0" == Parser.format_ip(<<0, 0, 0, 0>>) + assert "255.255.255.255" == Parser.format_ip(<<255, 255, 255, 255>>) + end + + test "valid IPv4 string passes through" do + assert "10.0.0.1" == Parser.format_ip("10.0.0.1") + assert "192.168.1.1" == Parser.format_ip("192.168.1.1") + end + + test "invalid IPv4 string returns nil" do + assert nil == Parser.format_ip("not an ip") + assert nil == Parser.format_ip("1.2.3") + assert nil == Parser.format_ip("1.2.3.4.5") + end + + test "non-binary input returns nil" do + assert nil == Parser.format_ip(nil) + assert nil == Parser.format_ip(42) + assert nil == Parser.format_ip({1, 2, 3, 4}) + end + end + + describe "best_value/2" do + test "prefers first argument if integer" do + assert 5 == Parser.best_value(5, 10) + assert 0 == Parser.best_value(0, 999) + assert -3 == Parser.best_value(-3, 10) + end + + test "falls back to second if first is not integer" do + assert 10 == Parser.best_value(nil, 10) + assert 10 == Parser.best_value("foo", 10) + assert 10 == Parser.best_value(3.14, 10) + end + + test "returns nil when neither is integer" do + assert nil == Parser.best_value(nil, nil) + assert nil == Parser.best_value("a", "b") + assert nil == Parser.best_value(1.0, 2.0) + end + end + + describe "parse_session_time/1" do + test "nil returns nil" do + assert nil == Parser.parse_session_time(nil) + end + + test "integer passes through" do + assert 42 == Parser.parse_session_time(42) + assert 0 == Parser.parse_session_time(0) + end + + test "integer string parses" do + assert 3600 == Parser.parse_session_time("3600") + assert 0 == Parser.parse_session_time("0") + end + + test "integer string with trailing garbage returns nil" do + # Integer.parse returns {int, rest}; we only accept {int, ""} + assert nil == Parser.parse_session_time("42 seconds") + assert nil == Parser.parse_session_time("1d 2h 3m") + end + + test "unparseable returns nil" do + assert nil == Parser.parse_session_time("abc") + assert nil == Parser.parse_session_time("") + end + + test "non-binary non-integer returns nil" do + assert nil == Parser.parse_session_time(:atom) + assert nil == Parser.parse_session_time([]) + assert nil == Parser.parse_session_time(3.14) + end + end + + describe "timeticks_to_seconds/1" do + test "integer divides by 100" do + assert 100 == Parser.timeticks_to_seconds(10_000) + assert 1 == Parser.timeticks_to_seconds(100) + assert 0 == Parser.timeticks_to_seconds(50) + assert 0 == Parser.timeticks_to_seconds(0) + end + + test "nil returns nil" do + assert nil == Parser.timeticks_to_seconds(nil) + end + + test "non-integer returns nil" do + assert nil == Parser.timeticks_to_seconds("100") + assert nil == Parser.timeticks_to_seconds(1.5) + end + end + + describe "property: format_ip" do + property "4-byte binaries produce valid dotted quad" do + check all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + result = Parser.format_ip(<>) + assert result == "#{a}.#{b}.#{c}.#{d}" + end + end + + property "valid IPv4 strings round-trip" do + check all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + str = "#{a}.#{b}.#{c}.#{d}" + assert str == Parser.format_ip(str) + end + end + end + + describe "property: format_mac" do + property "6-byte binaries always produce canonical lowercase colon form" do + check all(bytes <- list_of(integer(0..255), length: 6)) do + result = Parser.format_mac(:erlang.list_to_binary(bytes)) + assert Regex.match?(~r/\A[0-9a-f]{2}(:[0-9a-f]{2}){5}\z/, result) + end + end + end + + describe "property: timeticks_to_seconds" do + property "integers produce non-negative result for non-negative input" do + check all(ticks <- integer(0..100_000_000)) do + result = Parser.timeticks_to_seconds(ticks) + assert result >= 0 + assert result == div(ticks, 100) + end + end + end + + describe "property: best_value" do + property "always returns first integer argument or second if first fails" do + check all( + a <- one_of([integer(), constant(nil), string(:alphanumeric, max_length: 5)]), + b <- one_of([integer(), constant(nil), string(:alphanumeric, max_length: 5)]) + ) do + result = Parser.best_value(a, b) + + cond do + is_integer(a) -> assert result == a + is_integer(b) -> assert result == b + true -> assert result == nil + end + end + end + end +end diff --git a/test/towerops/snmp/wireless_client_discovery_test.exs b/test/towerops/snmp/wireless_client_discovery_test.exs new file mode 100644 index 00000000..8280c87c --- /dev/null +++ b/test/towerops/snmp/wireless_client_discovery_test.exs @@ -0,0 +1,31 @@ +defmodule Towerops.Snmp.WirelessClientDiscoveryTest do + @moduledoc """ + Unit tests for the main module. Pure helpers are already covered in the + Parser submodule test file. Here we test the dispatch and the `:ok, []` + fall-through for unknown vendor profiles. + """ + use ExUnit.Case, async: true + + alias Towerops.Snmp.WirelessClientDiscovery + + describe "discover_wireless_clients/2 dispatch" do + test "unknown vendor yields empty list without SNMP calls" do + # `client_opts` would only be used if an SNMP walk were attempted. + # Passing nil here proves the unknown-vendor branch short-circuits first. + assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, "unknown") + assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, nil) + assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, "") + end + end + + describe "detect_vendor_profile/1 (delegated)" do + test "returns nil for empty device" do + assert nil == WirelessClientDiscovery.detect_vendor_profile(%{}) + assert nil == WirelessClientDiscovery.detect_vendor_profile(%{sys_object_id: nil, manufacturer: nil}) + end + + test "detects MikroTik" do + assert "routeros" == WirelessClientDiscovery.detect_vendor_profile(%{manufacturer: "MikroTik", sys_object_id: nil}) + end + end +end diff --git a/test/towerops/sonar/client_test.exs b/test/towerops/sonar/client_test.exs new file mode 100644 index 00000000..3901a3e8 --- /dev/null +++ b/test/towerops/sonar/client_test.exs @@ -0,0 +1,186 @@ +defmodule Towerops.Sonar.ClientTest do + use ExUnit.Case, async: true + + alias Towerops.Sonar.Client + + @url "https://sonar.example.com" + @token "test-token" + + describe "query/4" do + test "returns {:ok, data} on 200 success" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/graphql" + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{@token}"] + + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert Jason.decode!(body)["query"] =~ "accounts" + + Req.Test.json(conn, %{"data" => %{"accounts" => %{"entities" => []}}}) + end) + + assert {:ok, data} = Client.query(@url, @token, "{ accounts { entities { id } } }") + assert data == %{"accounts" => %{"entities" => []}} + end + + test "returns :unauthorized on 401" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Client.query(@url, @token, "{ q }") + end + + test "returns :forbidden on 403" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{}) + end) + + assert {:error, :forbidden} = Client.query(@url, @token, "{ q }") + end + + test "returns {:rate_limited, seconds} on 429 with retry-after" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "120") + |> Plug.Conn.put_status(429) + |> Req.Test.json(%{}) + end) + + assert {:error, {:rate_limited, 120}} = Client.query(@url, @token, "{ q }") + end + + test "returns {:rate_limited, 60} on 429 with no retry-after" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(429) |> Req.Test.json(%{}) + end) + + assert {:error, {:rate_limited, 60}} = Client.query(@url, @token, "{ q }") + end + + test "returns {:rate_limited, 60} on 429 with non-integer retry-after" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "invalid") + |> Plug.Conn.put_status(429) + |> Req.Test.json(%{}) + end) + + assert {:error, {:rate_limited, 60}} = Client.query(@url, @token, "{ q }") + end + + test "returns graphql_errors on 200 with errors field" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"errors" => [%{"message" => "schema error"}]}) + end) + + assert {:error, {:graphql_errors, [%{"message" => "schema error"}]}} = + Client.query(@url, @token, "{ bad }") + end + + test "returns graphql_errors on 400 with errors field" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(400) + |> Req.Test.json(%{"errors" => [%{"message" => "bad query"}]}) + end) + + assert {:error, {:graphql_errors, _}} = Client.query(@url, @token, "{ bad }") + end + + test "returns {:unexpected_status, status} on other non-2xx" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(502) |> Req.Test.json(%{}) + end) + + assert {:error, {:unexpected_status, 502}} = Client.query(@url, @token, "{ q }") + end + + test "trims trailing slash from instance url" do + Req.Test.stub(Client, fn conn -> + assert conn.request_path == "/api/graphql" + Req.Test.json(conn, %{"data" => %{}}) + end) + + assert {:ok, _} = Client.query("#{@url}/", @token, "{ q }") + end + + test "sends variables in request body" do + Req.Test.stub(Client, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + payload = Jason.decode!(body) + assert payload["variables"] == %{"page" => 2, "limit" => 50} + Req.Test.json(conn, %{"data" => %{}}) + end) + + assert {:ok, _} = Client.query(@url, @token, "{ q }", %{"page" => 2, "limit" => 50}) + end + end + + describe "test_connection/2" do + test "returns ok when query succeeds" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => %{"accounts" => %{"entities" => []}}}) + end) + + assert {:ok, %{}} = Client.test_connection(@url, @token) + end + + test "returns error when query fails" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Client.test_connection(@url, @token) + end + end + + describe "list_accounts/2 with pagination" do + test "aggregates entities across pages" do + {:ok, agent} = Agent.start_link(fn -> 1 end) + + Req.Test.stub(Client, fn conn -> + page = Agent.get_and_update(agent, fn p -> {p, p + 1} end) + + {:ok, body, conn} = Plug.Conn.read_body(conn) + payload = Jason.decode!(body) + assert payload["variables"]["page"] == page + + Req.Test.json(conn, %{ + "data" => %{ + "accounts" => %{ + "entities" => [%{"id" => "acct-#{page}"}], + "page_info" => %{"page" => page, "total_pages" => 2} + } + } + }) + end) + + assert {:ok, entities} = Client.list_accounts(@url, @token) + assert length(entities) == 2 + assert Enum.map(entities, & &1["id"]) == ["acct-1", "acct-2"] + end + + test "stops at single page when total_pages is 1" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => %{ + "accounts" => %{ + "entities" => [%{"id" => "a"}, %{"id" => "b"}], + "page_info" => %{"page" => 1, "total_pages" => 1} + } + } + }) + end) + + assert {:ok, [%{"id" => "a"}, %{"id" => "b"}]} = Client.list_accounts(@url, @token) + end + + test "propagates errors" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Client.list_accounts(@url, @token) + end + end +end diff --git a/test/towerops/sonar/sync_test.exs b/test/towerops/sonar/sync_test.exs new file mode 100644 index 00000000..27d5586d --- /dev/null +++ b/test/towerops/sonar/sync_test.exs @@ -0,0 +1,131 @@ +defmodule Towerops.Sonar.SyncTest do + use ExUnit.Case, async: true + + alias Towerops.Sonar.Sync + + describe "sum_service_prices/1" do + test "sums all numeric price_override values" do + services = [ + %{"price_override" => 10.0}, + %{"price_override" => 25.5}, + %{"price_override" => 100} + ] + + assert 135.5 == Sync.sum_service_prices(services) + end + + test "ignores nil price_override" do + services = [%{"price_override" => 10.0}, %{"price_override" => nil}] + assert 10.0 == Sync.sum_service_prices(services) + end + + test "ignores non-numeric price_override" do + services = [ + %{"price_override" => "not a number"}, + %{"price_override" => 5.0}, + %{"other_field" => "ignored"} + ] + + assert 5.0 == Sync.sum_service_prices(services) + end + + test "returns 0.0 for empty list" do + assert 0.0 == Sync.sum_service_prices([]) + end + + test "handles integer and float mixed" do + services = [%{"price_override" => 10}, %{"price_override" => 2.5}] + assert 12.5 == Sync.sum_service_prices(services) + end + end + + describe "humanize_sync_error/1" do + test "unauthorized" do + assert Sync.humanize_sync_error(:unauthorized) =~ "Authentication failed" + end + + test "forbidden" do + assert Sync.humanize_sync_error(:forbidden) =~ "Access denied" + end + + test "rate_limited includes retry seconds" do + assert Sync.humanize_sync_error({:rate_limited, 120}) =~ "retry after 120s" + end + + test "unexpected_status includes status code" do + assert Sync.humanize_sync_error({:unexpected_status, 502}) =~ "HTTP 502" + end + + test "graphql_errors joins messages" do + errors = [%{"message" => "bad field"}, %{"message" => "missing arg"}] + msg = Sync.humanize_sync_error({:graphql_errors, errors}) + assert msg =~ "GraphQL errors" + assert msg =~ "bad field" + assert msg =~ "missing arg" + assert msg =~ ";" + end + + test "graphql_errors without message uses inspect" do + errors = [%{"foo" => "bar"}] + msg = Sync.humanize_sync_error({:graphql_errors, errors}) + assert msg =~ "GraphQL errors" + assert msg =~ "%{" + end + + test "SSL-related string errors return SSL message" do + for snippet <- ["CA trust store", "cacert", "certificate"] do + msg = Sync.humanize_sync_error("failure: #{snippet} something") + assert msg =~ "SSL certificate verification failed" + end + end + + test "other string errors return generic message" do + assert Sync.humanize_sync_error("random broken thing") == + "An unexpected error occurred during sync" + end + + test "non-binary unknown errors return generic message" do + assert Sync.humanize_sync_error(:something_weird) == + "An unexpected error occurred during sync" + + assert Sync.humanize_sync_error({:unknown_tuple, 42}) == + "An unexpected error occurred during sync" + end + end + + describe "add_price/2" do + test "adds a number to the accumulator" do + assert 15.5 == Sync.add_price(10, 5.5) + assert 20 == Sync.add_price(5, 15) + end + + test "nil price leaves accumulator unchanged" do + assert 10 == Sync.add_price(10, nil) + end + + test "non-numeric price leaves accumulator unchanged" do + assert 10 == Sync.add_price(10, "$5") + assert 10.0 == Sync.add_price(10.0, :invalid) + end + end + + describe "calculate_total_mrr/1" do + test "sums services across all accounts" do + accounts = [ + %{"account_services" => [%{"price_override" => 10.0}, %{"price_override" => 5.0}]}, + %{"account_services" => [%{"price_override" => 20}]} + ] + + assert 35.0 == Sync.calculate_total_mrr(accounts) + end + + test "handles missing account_services key" do + accounts = [%{"name" => "no services"}, %{"account_services" => [%{"price_override" => 10.0}]}] + assert 10.0 == Sync.calculate_total_mrr(accounts) + end + + test "handles empty list" do + assert 0.0 == Sync.calculate_total_mrr([]) + end + end +end diff --git a/test/towerops/topology/device_neighbor_test.exs b/test/towerops/topology/device_neighbor_test.exs new file mode 100644 index 00000000..be21df63 --- /dev/null +++ b/test/towerops/topology/device_neighbor_test.exs @@ -0,0 +1,53 @@ +defmodule Towerops.Topology.DeviceNeighborTest do + use Towerops.DataCase, async: true + + alias Towerops.Topology.DeviceNeighbor + + @valid_attrs %{ + device_id: Ecto.UUID.generate(), + neighbor_name: "router-01", + local_port: "ether1", + discovered_at: ~U[2026-01-15 10:00:00Z], + last_seen_at: ~U[2026-01-15 10:05:00Z] + } + + describe "changeset/2" do + test "valid with required fields" do + assert DeviceNeighbor.changeset(%DeviceNeighbor{}, @valid_attrs).valid? + end + + test "valid with optional neighbor_device_id and management_addresses" do + attrs = + Map.merge(@valid_attrs, %{ + neighbor_device_id: Ecto.UUID.generate(), + remote_port: "eth0/1", + remote_port_id: "abc-123", + management_addresses: ["10.0.0.1", "fe80::1"] + }) + + changeset = DeviceNeighbor.changeset(%DeviceNeighbor{}, attrs) + assert changeset.valid? + assert changeset.changes.management_addresses == ["10.0.0.1", "fe80::1"] + end + + for field <- [:device_id, :neighbor_name, :local_port, :discovered_at, :last_seen_at] do + test "invalid without #{field}" do + attrs = Map.delete(@valid_attrs, unquote(field)) + changeset = DeviceNeighbor.changeset(%DeviceNeighbor{}, attrs) + refute changeset.valid? + assert Map.has_key?(errors_on(changeset), unquote(field)) + end + end + + test "management_addresses defaults to empty list" do + assert %DeviceNeighbor{management_addresses: []} = %DeviceNeighbor{} + end + + test "ignores unknown fields" do + attrs = Map.put(@valid_attrs, :bogus, "value") + changeset = DeviceNeighbor.changeset(%DeviceNeighbor{}, attrs) + assert changeset.valid? + refute Map.has_key?(changeset.changes, :bogus) + end + end +end diff --git a/test/towerops/topology/lldp/parser_test.exs b/test/towerops/topology/lldp/parser_test.exs new file mode 100644 index 00000000..797a033e --- /dev/null +++ b/test/towerops/topology/lldp/parser_test.exs @@ -0,0 +1,210 @@ +defmodule Towerops.Topology.Lldp.ParserTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Topology.Lldp.Parser + + @remote_base "1.0.8802.1.1.2.1.4.1.1.9" + @man_addr_base "1.0.8802.1.1.2.1.4.2.1.3" + + describe "extract_suffix/2" do + test "returns suffix when oid starts with base.prefix" do + assert "1.2.3" == Parser.extract_suffix("#{@remote_base}.1.2.3", @remote_base) + end + + test "returns nil when oid does not start with base" do + assert nil == Parser.extract_suffix("9.9.9.9", @remote_base) + end + + test "returns nil when oid equals base without trailing dot" do + assert nil == Parser.extract_suffix(@remote_base, @remote_base) + end + + test "handles empty suffix after base." do + assert "" == Parser.extract_suffix("#{@remote_base}.", @remote_base) + end + end + + describe "parse_remote_key/2" do + test "parses 3-part suffix" do + oid = "#{@remote_base}.123.4.5" + assert {"123", "4", "5"} == Parser.parse_remote_key(oid, @remote_base) + end + + test "parses 4+-part suffix (drops tail)" do + oid = "#{@remote_base}.123.4.5.extra.more" + assert {"123", "4", "5"} == Parser.parse_remote_key(oid, @remote_base) + end + + test "returns nil when suffix has fewer than 3 parts" do + assert nil == Parser.parse_remote_key("#{@remote_base}.1.2", @remote_base) + end + + test "returns nil when oid does not start with base" do + assert nil == Parser.parse_remote_key("1.2.3.4", @remote_base) + end + end + + describe "parse_ipv4/1" do + test "joins 4 string octets with dots" do + assert "192.168.1.1" == Parser.parse_ipv4(["192", "168", "1", "1"]) + end + + test "returns nil for wrong-length lists" do + assert nil == Parser.parse_ipv4([]) + assert nil == Parser.parse_ipv4(["1", "2", "3"]) + assert nil == Parser.parse_ipv4(["1", "2", "3", "4", "5"]) + end + end + + describe "parse_ipv6/1" do + test "converts 16 octets to lowercase colon-separated hex" do + octets = List.duplicate("0", 14) ++ ["0", "1"] + assert "0:0:0:0:0:0:0:1" == Parser.parse_ipv6(octets) + end + + test "handles typical v6 address" do + # 2001:db8::1 = 32,1,13,184,0,0,0,0,0,0,0,0,0,0,0,1 + octets = + ["32", "1", "13", "184", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1"] + + assert "2001:db8:0:0:0:0:0:1" == Parser.parse_ipv6(octets) + end + + test "produces lowercase hex" do + octets = ["255", "255"] ++ List.duplicate("0", 14) + assert "ffff:0:0:0:0:0:0:0" == Parser.parse_ipv6(octets) + end + + test "returns nil for wrong-length lists" do + assert nil == Parser.parse_ipv6([]) + assert nil == Parser.parse_ipv6(List.duplicate("0", 15)) + assert nil == Parser.parse_ipv6(List.duplicate("0", 17)) + end + end + + describe "parse_ip_from_addr_subtype/2" do + test "subtype 1 uses IPv4 from positions 1..4" do + # Format is [addr_len | addr_bytes] + rest = ["4", "10", "0", "0", "1"] + assert "10.0.0.1" == Parser.parse_ip_from_addr_subtype("1", rest) + end + + test "subtype 2 uses IPv6 from positions 1..16" do + rest = ["16"] ++ List.duplicate("0", 15) ++ ["1"] + assert "0:0:0:0:0:0:0:1" == Parser.parse_ip_from_addr_subtype("2", rest) + end + + test "unknown subtype returns nil" do + assert nil == Parser.parse_ip_from_addr_subtype("99", ["4", "1", "2", "3", "4"]) + assert nil == Parser.parse_ip_from_addr_subtype("0", []) + end + + test "subtype 1 with too-short rest returns nil" do + assert nil == Parser.parse_ip_from_addr_subtype("1", ["4"]) + end + end + + describe "parse_management_address/2" do + test "parses a full IPv4 management address OID" do + oid = "#{@man_addr_base}.100.5.7.1.4.10.0.0.1" + assert {{"100", "5", "7"}, "10.0.0.1"} == Parser.parse_management_address(oid, @man_addr_base) + end + + test "parses a full IPv6 management address OID" do + v6_bytes = List.duplicate("0", 15) ++ ["1"] + oid = "#{@man_addr_base}.100.5.7.2.16." <> Enum.join(v6_bytes, ".") + + assert {{"100", "5", "7"}, "0:0:0:0:0:0:0:1"} == + Parser.parse_management_address(oid, @man_addr_base) + end + + test "returns nil when too few parts" do + oid = "#{@man_addr_base}.1.2.3.4.5" + assert nil == Parser.parse_management_address(oid, @man_addr_base) + end + + test "returns nil when oid does not start with base" do + assert nil == Parser.parse_management_address("9.9.9.9.1.2.3.4.5.6.7.8", @man_addr_base) + end + + test "returns nil for unknown subtype" do + oid = "#{@man_addr_base}.1.2.3.99.4.1.2.3.4" + assert nil == Parser.parse_management_address(oid, @man_addr_base) + end + end + + describe "clean_string/1" do + test "trims whitespace" do + assert "hello" == Parser.clean_string(" hello ") + end + + test "strips ASCII control characters" do + assert "abc" == Parser.clean_string("a\x00b\x01c") + assert "xyz" == Parser.clean_string("x\x1Fy\x7Fz") + end + + test "preserves printable punctuation and unicode" do + assert "hello, 世界!" == Parser.clean_string("hello, 世界!") + end + + test "empty string stays empty" do + assert "" == Parser.clean_string("") + end + + test "non-binary value is coerced via to_string" do + assert "42" == Parser.clean_string(42) + assert "true" == Parser.clean_string(true) + end + end + + describe "property: parse_ipv4" do + property "any valid 4-octet list produces a 4-part dotted string" do + check all( + a <- integer(0..255), + b <- integer(0..255), + c <- integer(0..255), + d <- integer(0..255) + ) do + result = Parser.parse_ipv4([to_string(a), to_string(b), to_string(c), to_string(d)]) + assert String.split(result, ".") == [to_string(a), to_string(b), to_string(c), to_string(d)] + end + end + + property "wrong-length inputs always return nil" do + check all( + length <- integer(0..10), + length != 4, + octets <- list_of(integer(0..255), length: length) + ) do + assert nil == Parser.parse_ipv4(Enum.map(octets, &to_string/1)) + end + end + end + + describe "property: parse_ipv6" do + property "valid 16-octet list always produces 8 colon-separated groups" do + check all(octets <- list_of(integer(0..255), length: 16)) do + result = Parser.parse_ipv6(Enum.map(octets, &to_string/1)) + groups = String.split(result, ":") + assert length(groups) == 8 + assert result == String.downcase(result) + end + end + end + + describe "property: round-trip extract_suffix / parse_remote_key" do + property "parse_remote_key reconstructs indices from synthesized OIDs" do + check all( + time_mark <- integer(0..999_999), + port_num <- integer(1..65_535), + rem_index <- integer(1..1_000_000) + ) do + oid = "#{@remote_base}.#{time_mark}.#{port_num}.#{rem_index}" + + assert {to_string(time_mark), to_string(port_num), to_string(rem_index)} == + Parser.parse_remote_key(oid, @remote_base) + end + end + end +end diff --git a/test/towerops/topology/lldp_test.exs b/test/towerops/topology/lldp_test.exs new file mode 100644 index 00000000..ed4ef396 --- /dev/null +++ b/test/towerops/topology/lldp_test.exs @@ -0,0 +1,36 @@ +defmodule Towerops.Topology.LldpTest do + use Towerops.DataCase, async: true + + alias Towerops.Topology.Lldp + + describe "discover_neighbors/2" do + test "returns :device_not_found for unknown device id" do + assert {:error, :device_not_found} == Lldp.discover_neighbors(Ecto.UUID.generate()) + end + end + + describe "delegated pure helpers" do + test "extract_suffix strips a matching prefix" do + assert "5" == Lldp.extract_suffix("1.0.8802.1.1.2.1.3.7.1.4.5", "1.0.8802.1.1.2.1.3.7.1.4") + end + + test "extract_suffix returns nil when no match" do + assert nil == Lldp.extract_suffix("1.2.3", "9.9") + end + + test "parse_remote_key parses timeMark.portNum.remIndex as strings" do + base = "1.0.8802.1.1.2.1.4.1.1.9" + assert {"0", "3", "1"} == Lldp.parse_remote_key("#{base}.0.3.1", base) + end + + test "parse_remote_key returns nil for malformed key" do + assert nil == Lldp.parse_remote_key("1.2.3", "9.9.9") + end + + test "clean_string trims/rejects junk" do + assert "hello" == Lldp.clean_string("hello") + assert "" == Lldp.clean_string(nil) + assert "abc" == Lldp.clean_string(<<97, 98, 99>>) + end + end +end diff --git a/test/towerops/trace_test.exs b/test/towerops/trace_test.exs index cb94b4c2..c68fab0c 100644 --- a/test/towerops/trace_test.exs +++ b/test/towerops/trace_test.exs @@ -75,5 +75,115 @@ defmodule Towerops.TraceTest do test "returns nil for non-existent site", %{organization: organization} do assert Trace.assemble_trace(organization.id, :site, Ecto.UUID.generate()) == nil end + + test "assembles trace from device", %{organization: organization, device: device} do + trace = Trace.assemble_trace(organization.id, :device, device.id) + + assert trace + assert trace.device.id == device.id + # No account/sub + assert trace.subscriber == nil + end + + test "returns nil for non-existent device", %{organization: organization} do + assert Trace.assemble_trace(organization.id, :device, Ecto.UUID.generate()) == nil + end + + test "returns nil for non-existent account", %{organization: organization} do + assert Trace.assemble_trace(organization.id, :account, Ecto.UUID.generate()) == nil + end + + test "returns nil for non-existent inventory item", %{organization: organization} do + assert Trace.assemble_trace(organization.id, :inventory_item, Ecto.UUID.generate()) == nil + end + + test "returns nil for non-existent access_point", %{organization: organization} do + assert Trace.assemble_trace(organization.id, :access_point, Ecto.UUID.generate()) == nil + end + + test "returns nil for unknown type", %{organization: organization} do + assert Trace.assemble_trace(organization.id, :unknown, Ecto.UUID.generate()) == nil + end + end + + describe "format_address/1" do + test "returns nil for nil" do + assert Trace.format_address(nil) == nil + end + + test "returns nil for empty map" do + assert Trace.format_address(%{}) == nil + end + + test "returns nil for non-map" do + assert Trace.format_address("string") == nil + assert Trace.format_address(123) == nil + assert Trace.format_address(:atom) == nil + end + + test "joins present components with commas" do + addr = %{"street1" => "123 Main", "city" => "Springfield", "state" => "IL", "zip" => "62701"} + assert "123 Main, Springfield, IL, 62701" == Trace.format_address(addr) + end + + test "prefers street1 over line1 and state over province" do + addr = %{"street1" => "first", "line1" => "alt", "state" => "S", "province" => "P"} + result = Trace.format_address(addr) + assert String.contains?(result, "first") + refute String.contains?(result, "alt") + assert String.contains?(result, "S") + end + + test "falls back to line1 when street1 is missing" do + addr = %{"line1" => "fallback", "city" => "City"} + assert "fallback, City" == Trace.format_address(addr) + end + + test "falls back to province/postal_code when state/zip absent" do + addr = %{"city" => "Montreal", "province" => "QC", "postal_code" => "H3A"} + assert "Montreal, QC, H3A" == Trace.format_address(addr) + end + + test "trims whitespace-only as blank" do + addr = %{"street1" => " ", "city" => "Real"} + assert "Real" == Trace.format_address(addr) + end + + test "skips nil components" do + addr = %{"street1" => nil, "city" => "Only"} + assert "Only" == Trace.format_address(addr) + end + + test "handles line2/street2 (included when set)" do + addr = %{"street1" => "a", "street2" => "b", "city" => "c"} + assert "a, b, c" == Trace.format_address(addr) + end + end + + describe "search/2 (edge cases)" do + test "returns empty list for whitespace query", %{organization: organization} do + assert Trace.search(organization.id, " ") == [] + assert Trace.search(organization.id, "\t\n") == [] + end + + test "matches device by IP", %{organization: organization} do + results = Trace.search(organization.id, "10.0.0") + device_results = Enum.filter(results, &(&1.type == :device)) + refute device_results == [] + end + + test "caps total results at 25", %{organization: organization, site: site} do + for i <- 1..30 do + Towerops.DevicesFixtures.device_fixture(%{ + name: "Queryable-#{i}", + ip_address: "10.99.0.#{i}", + site_id: site.id, + organization_id: organization.id + }) + end + + results = Trace.search(organization.id, "Queryable") + assert Enum.count(results) <= 25 + end end end diff --git a/test/towerops/uisp/config_snapshot_test.exs b/test/towerops/uisp/config_snapshot_test.exs new file mode 100644 index 00000000..52adbec3 --- /dev/null +++ b/test/towerops/uisp/config_snapshot_test.exs @@ -0,0 +1,245 @@ +defmodule Towerops.Uisp.ConfigSnapshotTest do + use Towerops.DataCase, async: true + use ExUnitProperties + + alias Towerops.Uisp.ConfigSnapshot + + describe "compute_diff/2" do + test "empty maps produce empty diff" do + assert %{added: [], removed: [], changed: []} == ConfigSnapshot.compute_diff(%{}, %{}) + end + + test "identical maps produce empty diff" do + map = %{"a" => 1, "b" => 2} + assert %{added: [], removed: [], changed: []} == ConfigSnapshot.compute_diff(map, map) + end + + test "detects added keys" do + result = ConfigSnapshot.compute_diff(%{}, %{"x" => 1, "y" => 2}) + assert result.removed == [] + assert result.changed == [] + assert Enum.sort(result.added) == [{"x", 1}, {"y", 2}] + end + + test "detects removed keys" do + result = ConfigSnapshot.compute_diff(%{"x" => 1, "y" => 2}, %{}) + assert result.added == [] + assert result.changed == [] + assert Enum.sort(result.removed) == [{"x", 1}, {"y", 2}] + end + + test "detects changed keys" do + result = ConfigSnapshot.compute_diff(%{"name" => "old"}, %{"name" => "new"}) + assert result.added == [] + assert result.removed == [] + assert result.changed == [{"name", "old", "new"}] + end + + test "handles mixed add/remove/change" do + a = %{"keep" => 1, "drop" => 2, "change" => "old"} + b = %{"keep" => 1, "change" => "new", "add" => 3} + + result = ConfigSnapshot.compute_diff(a, b) + assert result.added == [{"add", 3}] + assert result.removed == [{"drop", 2}] + assert result.changed == [{"change", "old", "new"}] + end + + test "nil value on b side counts as removed" do + result = ConfigSnapshot.compute_diff(%{"k" => "v"}, %{"k" => nil}) + assert result.removed == [{"k", "v"}] + assert result.added == [] + assert result.changed == [] + end + + test "nil value on a side counts as added" do + result = ConfigSnapshot.compute_diff(%{"k" => nil}, %{"k" => "v"}) + assert result.added == [{"k", "v"}] + assert result.removed == [] + assert result.changed == [] + end + + test "non-map inputs yield empty diff" do + empty = %{added: [], removed: [], changed: []} + assert empty == ConfigSnapshot.compute_diff(nil, nil) + assert empty == ConfigSnapshot.compute_diff("string", %{}) + assert empty == ConfigSnapshot.compute_diff(%{}, 42) + assert empty == ConfigSnapshot.compute_diff([], []) + end + end + + describe "property: compute_diff invariants" do + property "diffing a map with itself always yields empty diff" do + check all(map <- map_of(string(:alphanumeric), integer())) do + assert %{added: [], removed: [], changed: []} == ConfigSnapshot.compute_diff(map, map) + end + end + + property "compute_diff(a, b) swaps added/removed when arguments are flipped" do + check all( + a <- map_of(string(:alphanumeric), integer(), max_length: 10), + b <- map_of(string(:alphanumeric), integer(), max_length: 10) + ) do + forward = ConfigSnapshot.compute_diff(a, b) + reverse = ConfigSnapshot.compute_diff(b, a) + + assert Enum.sort(forward.added) == Enum.sort(reverse.removed) + assert Enum.sort(forward.removed) == Enum.sort(reverse.added) + end + end + + property "every key appears in at most one bucket" do + check all( + a <- map_of(string(:alphanumeric), integer(), max_length: 8), + b <- map_of(string(:alphanumeric), integer(), max_length: 8) + ) do + %{added: added, removed: removed, changed: changed} = ConfigSnapshot.compute_diff(a, b) + + added_keys = MapSet.new(added, fn {k, _} -> k end) + removed_keys = MapSet.new(removed, fn {k, _} -> k end) + changed_keys = MapSet.new(changed, fn {k, _, _} -> k end) + + assert MapSet.disjoint?(added_keys, removed_keys) + assert MapSet.disjoint?(added_keys, changed_keys) + assert MapSet.disjoint?(removed_keys, changed_keys) + end + end + + property "sum of bucket sizes equals count of non-identical keys" do + check all( + a <- map_of(string(:alphanumeric), integer(), max_length: 10), + b <- map_of(string(:alphanumeric), integer(), max_length: 10) + ) do + %{added: added, removed: removed, changed: changed} = ConfigSnapshot.compute_diff(a, b) + + all_keys = MapSet.union(MapSet.new(Map.keys(a)), MapSet.new(Map.keys(b))) + identical_count = Enum.count(all_keys, fn k -> Map.get(a, k) == Map.get(b, k) end) + + assert length(added) + length(removed) + length(changed) == + MapSet.size(all_keys) - identical_count + end + end + end + + describe "store_if_changed/3 + get_latest_snapshot/1 + list_snapshots/2 + get_config/1" do + setup do + user = Towerops.AccountsFixtures.user_fixture() + org = Towerops.OrganizationsFixtures.organization_fixture(user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "S #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + name: "dev-#{System.unique_integer([:positive])}" + }) + + %{device: device} + end + + test "stores first snapshot and increments :stored", %{device: device} do + acc = %{stored: 0, unchanged: 0} + config = %{"name" => "foo", "ip" => "10.0.0.1"} + + result = ConfigSnapshot.store_if_changed(device, config, acc) + assert result.stored == 1 + assert result.unchanged == 0 + + latest = ConfigSnapshot.get_latest_snapshot(device.id) + assert latest + assert byte_size(latest.config_hash) == 64 + end + + test "does not store when config hash unchanged", %{device: device} do + config = %{"a" => 1} + + first = ConfigSnapshot.store_if_changed(device, config, %{stored: 0, unchanged: 0}) + assert first.stored == 1 + + second = ConfigSnapshot.store_if_changed(device, config, %{stored: 0, unchanged: 0}) + assert second.stored == 0 + assert second.unchanged == 1 + end + + test "stores new snapshot when config changes", %{device: device} do + _first = ConfigSnapshot.store_if_changed(device, %{"a" => 1}, %{stored: 0, unchanged: 0}) + second = ConfigSnapshot.store_if_changed(device, %{"a" => 2}, %{stored: 0, unchanged: 0}) + + assert second.stored == 1 + assert [_, _] = ConfigSnapshot.list_snapshots(device.id) + end + + test "list_snapshots honours limit", %{device: device} do + ConfigSnapshot.store_if_changed(device, %{"v" => 1}, %{stored: 0, unchanged: 0}) + ConfigSnapshot.store_if_changed(device, %{"v" => 2}, %{stored: 0, unchanged: 0}) + ConfigSnapshot.store_if_changed(device, %{"v" => 3}, %{stored: 0, unchanged: 0}) + + assert length(ConfigSnapshot.list_snapshots(device.id, 2)) == 2 + end + + test "get_config returns decompressed config for stored snapshot", %{device: device} do + config = %{"ssid" => "towerops", "power" => 23} + ConfigSnapshot.store_if_changed(device, config, %{stored: 0, unchanged: 0}) + + [snap | _] = ConfigSnapshot.list_snapshots(device.id) + assert {:ok, restored} = ConfigSnapshot.get_config(snap.id) + assert restored == config + end + + test "get_config returns :not_found for unknown ID" do + assert {:error, :not_found} = ConfigSnapshot.get_config(Ecto.UUID.generate()) + end + + test "get_latest_snapshot returns nil with no snapshots", %{device: device} do + assert ConfigSnapshot.get_latest_snapshot(device.id) == nil + end + end + + describe "diff_snapshots/2" do + setup do + user = Towerops.AccountsFixtures.user_fixture() + org = Towerops.OrganizationsFixtures.organization_fixture(user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "S2 #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id + }) + + %{device: device} + end + + test "returns computed diff of two stored snapshots", %{device: device} do + ConfigSnapshot.store_if_changed(device, %{"a" => 1}, %{stored: 0, unchanged: 0}) + # Ensure distinct snapshot_at timestamps (second precision). + Process.sleep(1_100) + ConfigSnapshot.store_if_changed(device, %{"a" => 2, "b" => 3}, %{stored: 0, unchanged: 0}) + + [newer, older] = ConfigSnapshot.list_snapshots(device.id) + assert {:ok, diff} = ConfigSnapshot.diff_snapshots(older.id, newer.id) + assert diff.added == [{"b", 3}] + assert diff.changed == [{"a", 1, 2}] + assert diff.removed == [] + end + + test "propagates :not_found when either snapshot is missing", %{device: device} do + ConfigSnapshot.store_if_changed(device, %{"a" => 1}, %{stored: 0, unchanged: 0}) + [snap] = ConfigSnapshot.list_snapshots(device.id) + missing_id = Ecto.UUID.generate() + + assert {:error, :not_found} = ConfigSnapshot.diff_snapshots(snap.id, missing_id) + assert {:error, :not_found} = ConfigSnapshot.diff_snapshots(missing_id, snap.id) + end + end +end diff --git a/test/towerops/uisp/gps_sync_test.exs b/test/towerops/uisp/gps_sync_test.exs new file mode 100644 index 00000000..fda297d3 --- /dev/null +++ b/test/towerops/uisp/gps_sync_test.exs @@ -0,0 +1,114 @@ +defmodule Towerops.Uisp.GpsSyncTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Uisp.GpsSync + + describe "to_float/1" do + test "nil passes through" do + assert nil == GpsSync.to_float(nil) + end + + test "float passes through unchanged" do + assert 42.5 == GpsSync.to_float(42.5) + assert 0.0 == GpsSync.to_float(0.0) + assert -1.23 == GpsSync.to_float(-1.23) + end + + test "integer converts to float" do + assert 42.0 == GpsSync.to_float(42) + assert 0.0 == GpsSync.to_float(0) + assert -7.0 == GpsSync.to_float(-7) + end + + test "parseable string converts" do + assert 40.7128 == GpsSync.to_float("40.7128") + assert -74.006 == GpsSync.to_float("-74.006") + assert 10.0 == GpsSync.to_float("10") + end + + test "unparseable string returns nil" do + assert nil == GpsSync.to_float("not a number") + assert nil == GpsSync.to_float("") + end + + test "other types return nil" do + assert nil == GpsSync.to_float(:atom) + assert nil == GpsSync.to_float([]) + assert nil == GpsSync.to_float(%{}) + assert nil == GpsSync.to_float(true) + end + end + + describe "valid_coords?/2" do + test "true for valid non-zero floats" do + assert GpsSync.valid_coords?(40.7128, -74.006) + assert GpsSync.valid_coords?(-40.7, 74.0001) + end + + test "true for valid integers" do + assert GpsSync.valid_coords?(40, -74) + end + + test "true for parseable strings" do + assert GpsSync.valid_coords?("40.7", "-74.0") + end + + test "false when either coordinate is zero (null-island rejection)" do + refute GpsSync.valid_coords?(0.0, -74.006) + refute GpsSync.valid_coords?(40.7128, 0.0) + refute GpsSync.valid_coords?(0.0, 0.0) + refute GpsSync.valid_coords?(0, 0) + end + + test "false when either coordinate is nil" do + refute GpsSync.valid_coords?(nil, -74.006) + refute GpsSync.valid_coords?(40.7128, nil) + refute GpsSync.valid_coords?(nil, nil) + end + + test "false for unparseable strings" do + refute GpsSync.valid_coords?("abc", "def") + refute GpsSync.valid_coords?("40.7", "not a number") + end + end + + describe "property: to_float" do + property "integers round-trip through to_float as float with same value" do + check all(n <- integer(-1_000_000..1_000_000)) do + assert GpsSync.to_float(n) == n / 1 + end + end + + property "float input is returned unchanged" do + check all(f <- float()) do + assert GpsSync.to_float(f) == f + end + end + + property "to_float of Float.to_string/1 round-trips within tolerance" do + check all(f <- float(min: -180.0, max: 180.0)) do + result = GpsSync.to_float(Float.to_string(f)) + assert_in_delta result, f, 1.0e-6 + end + end + end + + describe "property: valid_coords?" do + property "non-zero integer pairs are always valid" do + check all( + lat <- one_of([integer(-90..-1), integer(1..90)]), + lon <- one_of([integer(-180..-1), integer(1..180)]) + ) do + assert GpsSync.valid_coords?(lat, lon) + end + end + + property "a zero for either coord is always invalid" do + check all(other <- integer(-180..180)) do + refute GpsSync.valid_coords?(0, other) + refute GpsSync.valid_coords?(other, 0) + end + end + end +end diff --git a/test/towerops/uisp/statistics_sync_test.exs b/test/towerops/uisp/statistics_sync_test.exs new file mode 100644 index 00000000..d1d74533 --- /dev/null +++ b/test/towerops/uisp/statistics_sync_test.exs @@ -0,0 +1,138 @@ +defmodule Towerops.Uisp.StatisticsSyncTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Uisp.StatisticsSync + + describe "parse_counter/1" do + test "nil becomes 0" do + assert 0 == StatisticsSync.parse_counter(nil) + end + + test "integer passes through" do + assert 42 == StatisticsSync.parse_counter(42) + assert 0 == StatisticsSync.parse_counter(0) + assert -5 == StatisticsSync.parse_counter(-5) + end + + test "float rounds to integer" do + assert 3 == StatisticsSync.parse_counter(3.4) + assert 4 == StatisticsSync.parse_counter(3.6) + assert 0 == StatisticsSync.parse_counter(0.0) + end + + test "integer-like binary parses" do + assert 1024 == StatisticsSync.parse_counter("1024") + assert 0 == StatisticsSync.parse_counter("0") + assert -7 == StatisticsSync.parse_counter("-7") + end + + test "binary with trailing junk still parses leading integer" do + assert 42 == StatisticsSync.parse_counter("42 bytes") + end + + test "unparseable binary returns 0" do + assert 0 == StatisticsSync.parse_counter("not a number") + assert 0 == StatisticsSync.parse_counter("") + end + + test "unknown types return 0" do + assert 0 == StatisticsSync.parse_counter(:atom) + assert 0 == StatisticsSync.parse_counter([]) + assert 0 == StatisticsSync.parse_counter(%{}) + end + end + + describe "parse_timestamp/1" do + test "nil returns nil" do + assert nil == StatisticsSync.parse_timestamp(nil) + end + + test "parses ISO8601 UTC string truncated to seconds" do + assert ~U[2026-03-15 10:30:45Z] == StatisticsSync.parse_timestamp("2026-03-15T10:30:45Z") + end + + test "parses ISO8601 with offset and converts to UTC" do + result = StatisticsSync.parse_timestamp("2026-03-15T10:30:45+00:00") + assert %DateTime{} = result + assert result.time_zone == "Etc/UTC" + end + + test "parses ISO8601 with sub-second precision but truncates" do + assert ~U[2026-03-15 10:30:45Z] == + StatisticsSync.parse_timestamp("2026-03-15T10:30:45.123456Z") + end + + test "returns nil for invalid string" do + assert nil == StatisticsSync.parse_timestamp("not a date") + assert nil == StatisticsSync.parse_timestamp("") + assert nil == StatisticsSync.parse_timestamp("2026-13-99") + end + + test "parses unix timestamp integer" do + # 2026-01-15 10:00:00 UTC = 1_768_514_400 + result = StatisticsSync.parse_timestamp(1_768_514_400) + assert %DateTime{} = result + assert DateTime.to_unix(result) == 1_768_514_400 + end + + test "unknown types return nil" do + assert nil == StatisticsSync.parse_timestamp(:atom) + assert nil == StatisticsSync.parse_timestamp(%{}) + assert nil == StatisticsSync.parse_timestamp([1, 2, 3]) + assert nil == StatisticsSync.parse_timestamp(3.14) + end + end + + describe "property: parse_counter/1" do + property "any integer is returned unchanged" do + check all(n <- integer(-1_000_000..1_000_000)) do + assert StatisticsSync.parse_counter(n) == n + end + end + + property "integer-as-string round-trips" do + check all(n <- integer(-1_000_000..1_000_000)) do + assert StatisticsSync.parse_counter(Integer.to_string(n)) == n + end + end + + property "float rounds correctly" do + check all(f <- float(min: -1_000_000.0, max: 1_000_000.0)) do + assert StatisticsSync.parse_counter(f) == round(f) + end + end + end + + describe "property: parse_timestamp/1" do + property "unix epoch integers round-trip via parse_timestamp" do + check all(ts <- integer(0..4_000_000_000)) do + result = StatisticsSync.parse_timestamp(ts) + assert %DateTime{} = result + assert DateTime.to_unix(result) == ts + end + end + + property "ISO8601 UTC strings round-trip" do + check all( + year <- integer(2000..2050), + month <- integer(1..12), + day <- integer(1..28), + hour <- integer(0..23), + minute <- integer(0..59), + second <- integer(0..59) + ) do + iso = + "#{year}-#{pad(month)}-#{pad(day)}T#{pad(hour)}:#{pad(minute)}:#{pad(second)}Z" + + result = StatisticsSync.parse_timestamp(iso) + assert %DateTime{} = result + assert result.year == year + assert result.month == month + assert result.day == day + end + end + end + + defp pad(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0") +end diff --git a/test/towerops/weather/alert_test.exs b/test/towerops/weather/alert_test.exs new file mode 100644 index 00000000..cda9fb0b --- /dev/null +++ b/test/towerops/weather/alert_test.exs @@ -0,0 +1,47 @@ +defmodule Towerops.Weather.AlertTest do + use Towerops.DataCase, async: true + + alias Towerops.Weather.Alert, as: WeatherAlert + + describe "changeset/2" do + test "valid with required fields" do + attrs = %{site_id: Ecto.UUID.generate(), event: "Severe Thunderstorm"} + assert WeatherAlert.changeset(%WeatherAlert{}, attrs).valid? + end + + test "invalid without site_id" do + changeset = WeatherAlert.changeset(%WeatherAlert{}, %{event: "Tornado"}) + refute changeset.valid? + assert %{site_id: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid without event" do + changeset = WeatherAlert.changeset(%WeatherAlert{}, %{site_id: Ecto.UUID.generate()}) + refute changeset.valid? + assert %{event: ["can't be blank"]} = errors_on(changeset) + end + + test "casts all optional fields" do + attrs = %{ + site_id: Ecto.UUID.generate(), + event: "High Wind Warning", + sender: "NWS", + description: "Sustained winds 40+ mph", + starts_at: ~U[2026-01-15 10:00:00Z], + ends_at: ~U[2026-01-15 22:00:00Z], + tags: ["wind", "severe"], + severity: "moderate" + } + + changeset = WeatherAlert.changeset(%WeatherAlert{}, attrs) + assert changeset.valid? + assert changeset.changes.sender == "NWS" + assert changeset.changes.tags == ["wind", "severe"] + assert changeset.changes.severity == "moderate" + end + + test "tags defaults to empty list" do + assert %WeatherAlert{tags: []} = %WeatherAlert{} + end + end +end diff --git a/test/towerops/weather/client_test.exs b/test/towerops/weather/client_test.exs new file mode 100644 index 00000000..2cb10f22 --- /dev/null +++ b/test/towerops/weather/client_test.exs @@ -0,0 +1,111 @@ +defmodule Towerops.Weather.ClientTest do + use ExUnit.Case, async: false + + alias Towerops.Weather.Client + + @api_key "test-owm-key" + + setup do + original = Application.get_env(:towerops, :openweathermap_api_key) + Application.put_env(:towerops, :openweathermap_api_key, @api_key) + + on_exit(fn -> + case original do + nil -> Application.delete_env(:towerops, :openweathermap_api_key) + value -> Application.put_env(:towerops, :openweathermap_api_key, value) + end + end) + + :ok + end + + describe "get_current_weather/3" do + test "returns body on 200" do + response = %{ + "weather" => [%{"main" => "Clear"}], + "main" => %{"temp" => 290.0} + } + + Req.Test.stub(Client, fn conn -> + assert conn.query_string =~ "lat=40.7" + assert conn.query_string =~ "lon=-74.0" + assert conn.query_string =~ "appid=#{@api_key}" + Req.Test.json(conn, response) + end) + + assert {:ok, body} = Client.get_current_weather(40.7, -74.0) + assert body == response + end + + test "returns :invalid_api_key on 401" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"cod" => 401, "message" => "Invalid API key"}) + end) + + assert {:error, :invalid_api_key} = Client.get_current_weather(40.7, -74.0) + end + + test "returns :rate_limited on 429" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(429) + |> Req.Test.json(%{"cod" => 429}) + end) + + assert {:error, :rate_limited} = Client.get_current_weather(40.7, -74.0) + end + + test "returns {:api_error, status} on other non-2xx" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(500) + |> Req.Test.json(%{"cod" => 500}) + end) + + assert {:error, {:api_error, 500}} = Client.get_current_weather(40.7, -74.0) + end + + test "returns :no_api_key when key missing in config" do + Application.delete_env(:towerops, :openweathermap_api_key) + + assert {:error, :no_api_key} = Client.get_current_weather(40.7, -74.0) + end + + test "returns :no_api_key when key is empty string" do + Application.put_env(:towerops, :openweathermap_api_key, "") + + assert {:error, :no_api_key} = Client.get_current_weather(40.7, -74.0) + end + + test "accepts api_key option override" do + Req.Test.stub(Client, fn conn -> + assert conn.query_string =~ "appid=override-key" + Req.Test.json(conn, %{}) + end) + + assert {:ok, _} = Client.get_current_weather(40.7, -74.0, api_key: "override-key") + end + end + + describe "test_connection/1" do + test "makes request with provided api key" do + Req.Test.stub(Client, fn conn -> + assert conn.query_string =~ "appid=my-test-key" + assert conn.query_string =~ "lat=51.5074" + Req.Test.json(conn, %{"ok" => true}) + end) + + assert {:ok, %{"ok" => true}} = Client.test_connection("my-test-key") + end + + test "returns :invalid_api_key on 401" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :invalid_api_key} = Client.test_connection("bad-key") + end + end +end diff --git a/test/towerops/weather/observation_test.exs b/test/towerops/weather/observation_test.exs new file mode 100644 index 00000000..53acc7db --- /dev/null +++ b/test/towerops/weather/observation_test.exs @@ -0,0 +1,163 @@ +defmodule Towerops.Weather.ObservationTest do + use Towerops.DataCase, async: true + + alias Towerops.Weather.Observation + + @site_id Ecto.UUID.generate() + + describe "changeset/2" do + test "valid with required fields" do + attrs = %{site_id: @site_id, observed_at: ~U[2026-01-15 10:00:00Z]} + assert Observation.changeset(%Observation{}, attrs).valid? + end + + test "invalid without site_id" do + changeset = Observation.changeset(%Observation{}, %{observed_at: ~U[2026-01-15 10:00:00Z]}) + refute changeset.valid? + assert %{site_id: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid without observed_at" do + changeset = Observation.changeset(%Observation{}, %{site_id: @site_id}) + refute changeset.valid? + assert %{observed_at: ["can't be blank"]} = errors_on(changeset) + end + + test "casts all optional fields" do + attrs = %{ + site_id: @site_id, + observed_at: ~U[2026-01-15 10:00:00Z], + condition: "Rain", + condition_detail: "light rain", + icon: "10d", + temperature_c: 15.5, + feels_like_c: 14.0, + humidity: 80, + pressure_hpa: 1013, + visibility_m: 10_000, + wind_speed_ms: 5.5, + wind_gust_ms: 8.0, + wind_deg: 180, + rain_1h_mm: 2.5, + snow_1h_mm: 0.0, + clouds_pct: 75, + raw: %{"foo" => "bar"} + } + + changeset = Observation.changeset(%Observation{}, attrs) + assert changeset.valid? + assert changeset.changes.condition == "Rain" + assert changeset.changes.temperature_c == 15.5 + assert changeset.changes.raw == %{"foo" => "bar"} + end + end + + describe "from_owm/2" do + test "parses a full OWM response" do + data = %{ + "weather" => [ + %{"main" => "Clouds", "description" => "broken clouds", "icon" => "04d"} + ], + "main" => %{ + "temp" => 288.15, + "feels_like" => 287.0, + "humidity" => 72, + "pressure" => 1015 + }, + "wind" => %{"speed" => 3.5, "gust" => 6.1, "deg" => 200}, + "rain" => %{"1h" => 1.2}, + "snow" => %{"1h" => 0.0}, + "clouds" => %{"all" => 75}, + "visibility" => 10_000, + "dt" => 1_705_320_000 + } + + attrs = Observation.from_owm(@site_id, data) + + assert attrs.site_id == @site_id + assert attrs.condition == "Clouds" + assert attrs.condition_detail == "broken clouds" + assert attrs.icon == "04d" + assert attrs.temperature_c == 15.0 + assert attrs.feels_like_c == 13.9 + assert attrs.humidity == 72 + assert attrs.pressure_hpa == 1015 + assert attrs.visibility_m == 10_000 + assert attrs.wind_speed_ms == 3.5 + assert attrs.wind_gust_ms == 6.1 + assert attrs.wind_deg == 200 + assert attrs.rain_1h_mm == 1.2 + assert attrs.snow_1h_mm == 0.0 + assert attrs.clouds_pct == 75 + assert attrs.observed_at == ~U[2024-01-15 12:00:00Z] + assert attrs.raw == data + end + + test "handles empty weather array" do + data = %{"weather" => [], "main" => %{}, "dt" => 1_705_320_000} + attrs = Observation.from_owm(@site_id, data) + + assert attrs.condition == nil + assert attrs.condition_detail == nil + assert attrs.icon == nil + end + + test "handles missing weather key" do + data = %{"main" => %{}, "dt" => 1_705_320_000} + attrs = Observation.from_owm(@site_id, data) + + assert attrs.condition == nil + end + + test "handles missing main key" do + data = %{"weather" => [%{"main" => "Clear"}], "dt" => 1_705_320_000} + attrs = Observation.from_owm(@site_id, data) + + assert attrs.temperature_c == nil + assert attrs.humidity == nil + assert attrs.pressure_hpa == nil + end + + test "handles missing wind/rain/snow/clouds" do + data = %{"weather" => [%{"main" => "Clear"}], "main" => %{}, "dt" => 1_705_320_000} + attrs = Observation.from_owm(@site_id, data) + + assert attrs.wind_speed_ms == nil + assert attrs.wind_gust_ms == nil + assert attrs.wind_deg == nil + assert attrs.rain_1h_mm == nil + assert attrs.snow_1h_mm == nil + assert attrs.clouds_pct == nil + end + + test "converts kelvin to celsius with one decimal" do + data = %{ + "weather" => [], + "main" => %{"temp" => 273.15, "feels_like" => 300.0}, + "dt" => 1_705_320_000 + } + + attrs = Observation.from_owm(@site_id, data) + + assert attrs.temperature_c == 0.0 + assert attrs.feels_like_c == 26.9 + end + + test "defaults observed_at to utc_now when dt is missing" do + before = DateTime.utc_now(:second) + data = %{"weather" => [], "main" => %{}} + attrs = Observation.from_owm(@site_id, data) + later = DateTime.utc_now(:second) + + assert DateTime.compare(attrs.observed_at, before) in [:eq, :gt] + assert DateTime.compare(attrs.observed_at, later) in [:eq, :lt] + end + + test "defaults observed_at when dt is not an integer" do + data = %{"weather" => [], "main" => %{}, "dt" => "bogus"} + attrs = Observation.from_owm(@site_id, data) + + assert %DateTime{} = attrs.observed_at + end + end +end diff --git a/test/towerops/weather_test.exs b/test/towerops/weather_test.exs new file mode 100644 index 00000000..46f5bd3a --- /dev/null +++ b/test/towerops/weather_test.exs @@ -0,0 +1,466 @@ +defmodule Towerops.WeatherTest do + use Towerops.DataCase, async: true + + alias Towerops.Weather + alias Towerops.Weather.Observation + + defp site_fixture(attrs \\ %{}) do + user = Towerops.AccountsFixtures.user_fixture() + + {:ok, org} = + Towerops.Organizations.create_organization(%{name: "WX Org #{System.unique_integer([:positive])}"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site( + Map.merge( + %{ + name: "WX Site #{System.unique_integer([:positive])}", + organization_id: org.id, + latitude: 45.0, + longitude: -93.0 + }, + attrs + ) + ) + + {site, org} + end + + describe "severe?/1" do + test "returns false for nil" do + refute Weather.severe?(nil) + end + + test "returns false for non-Observation struct" do + refute Weather.severe?(%{}) + end + + test "returns false for clear conditions" do + obs = %Observation{ + wind_speed_ms: 5.0, + wind_gust_ms: 10.0, + rain_1h_mm: 0.0, + snow_1h_mm: 0.0, + condition: "Clear" + } + + refute Weather.severe?(obs) + end + + test "returns true when wind > 15 m/s" do + obs = %Observation{wind_speed_ms: 16.0} + assert Weather.severe?(obs) + end + + test "returns false when wind exactly at threshold" do + obs = %Observation{wind_speed_ms: 15.0} + refute Weather.severe?(obs) + end + + test "returns true when gust > 20 m/s" do + obs = %Observation{wind_gust_ms: 21.0} + assert Weather.severe?(obs) + end + + test "returns true when rain > 10 mm/h" do + obs = %Observation{rain_1h_mm: 11.0} + assert Weather.severe?(obs) + end + + test "returns true when snow > 5 mm/h" do + obs = %Observation{snow_1h_mm: 5.1} + assert Weather.severe?(obs) + end + + test "returns true for thunderstorm" do + obs = %Observation{condition: "Thunderstorm"} + assert Weather.severe?(obs) + end + + test "handles nil numeric fields safely" do + obs = %Observation{ + wind_speed_ms: nil, + wind_gust_ms: nil, + rain_1h_mm: nil, + snow_1h_mm: nil, + condition: nil + } + + refute Weather.severe?(obs) + end + end + + describe "severity/1" do + test "returns :clear for nil" do + assert Weather.severity(nil) == :clear + end + + test "returns :clear for empty observation" do + assert Weather.severity(%Observation{}) == :clear + end + + test "returns :severe for thunderstorm" do + obs = %Observation{condition: "Thunderstorm"} + assert Weather.severity(obs) == :severe + end + + test "returns :severe for very strong gust" do + obs = %Observation{wind_gust_ms: 26.0} + assert Weather.severity(obs) == :severe + end + + test "returns :severe for heavy rain > 20 mm/h" do + obs = %Observation{rain_1h_mm: 21.0} + assert Weather.severity(obs) == :severe + end + + test "returns :moderate for high wind" do + obs = %Observation{wind_speed_ms: 16.0} + assert Weather.severity(obs) == :moderate + end + + test "returns :moderate for moderate gust (> 20, <= 25)" do + obs = %Observation{wind_gust_ms: 22.0} + assert Weather.severity(obs) == :moderate + end + + test "returns :moderate for heavy rain (> 10, <= 20)" do + obs = %Observation{rain_1h_mm: 15.0} + assert Weather.severity(obs) == :moderate + end + + test "returns :moderate for heavy snow" do + obs = %Observation{snow_1h_mm: 6.0} + assert Weather.severity(obs) == :moderate + end + + test "returns :mild for rain condition" do + obs = %Observation{condition: "Rain"} + assert Weather.severity(obs) == :mild + end + + test "returns :mild for drizzle" do + obs = %Observation{condition: "Drizzle"} + assert Weather.severity(obs) == :mild + end + + test "returns :mild for snow condition" do + obs = %Observation{condition: "Snow"} + assert Weather.severity(obs) == :mild + end + + test "returns :mild for overcast (clouds > 80%)" do + obs = %Observation{clouds_pct: 90} + assert Weather.severity(obs) == :mild + end + + test "returns :clear for light clouds" do + obs = %Observation{clouds_pct: 50, condition: "Clouds"} + assert Weather.severity(obs) == :clear + end + + test "severity takes priority over mildness" do + obs = %Observation{condition: "Thunderstorm", clouds_pct: 100} + assert Weather.severity(obs) == :severe + end + end + + describe "create_observation/1 and latest_observation/1" do + test "creates and retrieves latest observation" do + {site, _org} = site_fixture() + + {:ok, obs1} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.add(DateTime.utc_now(), -3600, :second), + condition: "Clear", + temperature_c: 20.0 + }) + + {:ok, obs2} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.utc_now(), + condition: "Rain", + temperature_c: 15.0 + }) + + latest = Weather.latest_observation(site.id) + assert latest.id == obs2.id + assert latest.id != obs1.id + end + + test "returns nil when no observations" do + {site, _org} = site_fixture() + assert Weather.latest_observation(site.id) == nil + end + end + + describe "latest_observations_for_org/1" do + test "returns map of site_id => latest observation" do + {site1, org} = site_fixture() + + {:ok, site2} = + Towerops.Sites.create_site(%{ + name: "Site 2 #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + {:ok, _old} = + Weather.create_observation(%{ + site_id: site1.id, + observed_at: DateTime.add(DateTime.utc_now(), -7200, :second), + condition: "Clear" + }) + + {:ok, newer} = + Weather.create_observation(%{ + site_id: site1.id, + observed_at: DateTime.utc_now(), + condition: "Rain" + }) + + {:ok, only} = + Weather.create_observation(%{ + site_id: site2.id, + observed_at: DateTime.utc_now(), + condition: "Snow" + }) + + result = Weather.latest_observations_for_org(org.id) + assert result[site1.id].id == newer.id + assert result[site2.id].id == only.id + end + + test "returns empty map when no observations" do + {_, org} = site_fixture() + assert Weather.latest_observations_for_org(org.id) == %{} + end + end + + describe "observations_for_site/2" do + test "returns observations within time range" do + {site, _org} = site_fixture() + + {:ok, _recent} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.utc_now(), + condition: "Clear" + }) + + {:ok, _old} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.add(DateTime.utc_now(), -48, :hour), + condition: "Rain" + }) + + results = Weather.observations_for_site(site.id) + assert length(results) == 1 + end + + test "honours custom after and limit" do + {site, _org} = site_fixture() + + for i <- 1..5 do + {:ok, _} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.add(DateTime.utc_now(), -i * 60, :second), + condition: "Clear" + }) + end + + results = + Weather.observations_for_site(site.id, + after: DateTime.add(DateTime.utc_now(), -10, :minute), + limit: 2 + ) + + assert length(results) == 2 + end + end + + describe "cleanup_old_observations/1" do + test "deletes observations older than keep_days" do + {site, _org} = site_fixture() + + {:ok, _old} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.add(DateTime.utc_now(), -31, :day), + condition: "Old" + }) + + {:ok, _recent} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.utc_now(), + condition: "Recent" + }) + + {:ok, count} = Weather.cleanup_old_observations(30) + assert count >= 1 + + remaining = Weather.observations_for_site(site.id, after: ~U[1970-01-01 00:00:00Z], limit: 100) + assert length(remaining) == 1 + end + end + + describe "upsert_alert/1 and active_alerts_for_site/1" do + test "creates alert and lists active ones" do + {site, _org} = site_fixture() + + {:ok, _alert} = + Weather.upsert_alert(%{ + site_id: site.id, + event: "Tornado Warning", + severity: "severe", + starts_at: DateTime.utc_now(), + ends_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + + active = Weather.active_alerts_for_site(site.id) + assert length(active) == 1 + end + + test "excludes expired alerts" do + {site, _org} = site_fixture() + + {:ok, _expired} = + Weather.upsert_alert(%{ + site_id: site.id, + event: "Expired", + severity: "moderate", + starts_at: DateTime.add(DateTime.utc_now(), -7200, :second), + ends_at: DateTime.add(DateTime.utc_now(), -3600, :second) + }) + + assert Weather.active_alerts_for_site(site.id) == [] + end + + test "alerts with nil ends_at are always active" do + {site, _org} = site_fixture() + + {:ok, _alert} = + Weather.upsert_alert(%{ + site_id: site.id, + event: "Ongoing", + severity: "mild", + starts_at: DateTime.utc_now(), + ends_at: nil + }) + + assert length(Weather.active_alerts_for_site(site.id)) == 1 + end + end + + describe "active_alerts_for_org/1" do + test "returns active alerts across all sites of an org" do + {site1, org} = site_fixture() + + {:ok, site2} = + Towerops.Sites.create_site(%{ + name: "Site 2 #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + {:ok, _a1} = + Weather.upsert_alert(%{ + site_id: site1.id, + event: "Wind", + severity: "moderate", + starts_at: DateTime.utc_now(), + ends_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + + {:ok, _a2} = + Weather.upsert_alert(%{ + site_id: site2.id, + event: "Snow", + severity: "mild", + starts_at: DateTime.utc_now(), + ends_at: nil + }) + + alerts = Weather.active_alerts_for_org(org.id) + assert length(alerts) == 2 + end + end + + describe "severe_weather_near?/3" do + test "true when any observation in window is severe" do + {site, _org} = site_fixture() + t = DateTime.utc_now() + + {:ok, _} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: t, + wind_speed_ms: 20.0, + condition: "Windy" + }) + + assert Weather.severe_weather_near?(site.id, t) + end + + test "false when no observations in window" do + {site, _org} = site_fixture() + refute Weather.severe_weather_near?(site.id, DateTime.utc_now()) + end + + test "false when observations in window are all mild" do + {site, _org} = site_fixture() + t = DateTime.utc_now() + + {:ok, _} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: t, + wind_speed_ms: 5.0, + condition: "Clear" + }) + + refute Weather.severe_weather_near?(site.id, t) + end + end + + describe "weather_at/2" do + test "returns closest observation within 30 min window" do + {site, _org} = site_fixture() + target = DateTime.utc_now() + + {:ok, far} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.add(target, -29, :minute), + condition: "Far" + }) + + {:ok, near} = + Weather.create_observation(%{ + site_id: site.id, + observed_at: DateTime.add(target, -1, :minute), + condition: "Near" + }) + + result = Weather.weather_at(site.id, target) + assert result.id == near.id + assert result.id != far.id + end + + test "returns nil when no observations nearby" do + {site, _org} = site_fixture() + assert Weather.weather_at(site.id, DateTime.utc_now()) == nil + end + end + + describe "fetch_and_store/1" do + test "returns :no_coordinates when site lacks lat/lon" do + assert {:error, :no_coordinates} = Weather.fetch_and_store(%{latitude: nil, longitude: nil}) + assert {:error, :no_coordinates} = Weather.fetch_and_store(%{}) + assert {:error, :no_coordinates} = Weather.fetch_and_store(%{latitude: 45.0, longitude: nil}) + end + end +end diff --git a/test/towerops/workers/alert_digest_worker_test.exs b/test/towerops/workers/alert_digest_worker_test.exs new file mode 100644 index 00000000..ac0b8c3d --- /dev/null +++ b/test/towerops/workers/alert_digest_worker_test.exs @@ -0,0 +1,70 @@ +defmodule Towerops.Workers.AlertDigestWorkerTest do + use Towerops.DataCase + use Oban.Testing, repo: Towerops.Repo + + import Towerops.AccountsFixtures + + alias Towerops.Alerts.NotificationDigest + alias Towerops.Workers.AlertDigestWorker + + defp insert_digest!(opts) do + user_id = Keyword.fetch!(opts, :user_id) + digest_sent = Keyword.get(opts, :digest_sent, false) + alert_ids = Keyword.get(opts, :alert_ids, [Ecto.UUID.generate()]) + + {:ok, org} = + Towerops.Organizations.create_organization( + %{name: "Org #{System.unique_integer([:positive])}"}, + user_id + ) + + {:ok, digest} = + Towerops.Repo.insert(%NotificationDigest{ + user_id: user_id, + organization_id: org.id, + suppressed_alert_ids: alert_ids, + digest_sent: digest_sent, + window_start: DateTime.truncate(DateTime.utc_now(), :second) + }) + + digest + end + + describe "perform/1 cron mode" do + test "enqueues delivery jobs per user with pending digest" do + user = user_fixture() + _ = insert_digest!(user_id: user.id) + + assert :ok = perform_job(AlertDigestWorker, %{"user_id" => "__cron__"}) + assert [_j] = all_enqueued(worker: AlertDigestWorker) + end + + test "doesn't enqueue when no pending digests exist" do + assert :ok = perform_job(AlertDigestWorker, %{"user_id" => "__cron__"}) + assert [] = all_enqueued(worker: AlertDigestWorker) + end + + test "skips users whose digests are already sent" do + user = user_fixture() + _ = insert_digest!(user_id: user.id, digest_sent: true) + + assert :ok = perform_job(AlertDigestWorker, %{"user_id" => "__cron__"}) + assert [] = all_enqueued(worker: AlertDigestWorker) + end + end + + describe "perform/1 delivery mode" do + test "no-op when there's no pending digest for user" do + user = user_fixture() + assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id}) + end + end + + describe "enqueue/1" do + test "schedules a delivery job 5 minutes out" do + user = user_fixture() + assert {:ok, _job} = AlertDigestWorker.enqueue(user.id) + assert [_j] = all_enqueued(worker: AlertDigestWorker) + end + end +end diff --git a/test/towerops/workers/alert_notification_worker_test.exs b/test/towerops/workers/alert_notification_worker_test.exs index 8ab3182a..6985a42e 100644 --- a/test/towerops/workers/alert_notification_worker_test.exs +++ b/test/towerops/workers/alert_notification_worker_test.exs @@ -2,6 +2,7 @@ defmodule Towerops.Workers.AlertNotificationWorkerTest do use Towerops.DataCase, async: false alias Towerops.Organizations + alias Towerops.Workers.AlertNotificationWorker setup do user = Towerops.AccountsFixtures.user_fixture() @@ -44,4 +45,77 @@ defmodule Towerops.Workers.AlertNotificationWorkerTest do assert reloaded.alert_routing == "pagerduty" end end + + describe "enqueue helpers" do + defp args_get(args, key) do + Map.get(args, key) || Map.get(args, String.to_atom(key)) + end + + test "enqueue_trigger inserts an oban job with trigger action" do + assert {:ok, job} = AlertNotificationWorker.enqueue_trigger("alert-1") + assert %Oban.Job{args: args, worker: "Towerops.Workers.AlertNotificationWorker"} = job + assert "trigger" == args_get(args, "action") + assert "alert-1" == args_get(args, "alert_id") + end + + test "enqueue_acknowledge inserts an oban job with acknowledge action" do + assert {:ok, job} = AlertNotificationWorker.enqueue_acknowledge("alert-2") + assert "acknowledge" == args_get(job.args, "action") + assert "alert-2" == args_get(job.args, "alert_id") + end + + test "enqueue_resolve inserts an oban job with resolve action" do + assert {:ok, job} = AlertNotificationWorker.enqueue_resolve("alert-3") + assert "resolve" == args_get(job.args, "action") + assert "alert-3" == args_get(job.args, "alert_id") + end + end + + describe "perform/1 — alert not found" do + test "trigger returns :ok when alert is missing" do + job = %Oban.Job{args: %{"action" => "trigger", "alert_id" => Ecto.UUID.generate()}} + assert :ok == AlertNotificationWorker.perform(job) + end + + test "acknowledge returns :ok when alert is missing" do + job = %Oban.Job{args: %{"action" => "acknowledge", "alert_id" => Ecto.UUID.generate()}} + assert :ok == AlertNotificationWorker.perform(job) + end + + test "resolve returns :ok when alert is missing" do + job = %Oban.Job{args: %{"action" => "resolve", "alert_id" => Ecto.UUID.generate()}} + assert :ok == AlertNotificationWorker.perform(job) + end + end + + describe "perform/1 — device missing" do + test "trigger returns :ok when device of alert is missing", %{organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Site-#{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + name: "D1" + }) + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.utc_now(), + message: "down" + }) + + # Delete the device, but keep the alert + Towerops.Repo.delete!(device) + + job = %Oban.Job{args: %{"action" => "trigger", "alert_id" => alert.id}} + assert :ok == AlertNotificationWorker.perform(job) + end + end end diff --git a/test/towerops/workers/check_worker_state_test.exs b/test/towerops/workers/check_worker_state_test.exs new file mode 100644 index 00000000..ce84be9d --- /dev/null +++ b/test/towerops/workers/check_worker_state_test.exs @@ -0,0 +1,93 @@ +defmodule Towerops.Workers.CheckWorkerStateTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Towerops.Workers.CheckWorker + + describe "state_changed_to_problem?/2" do + test "OK → CRITICAL hard is a problem" do + assert CheckWorker.state_changed_to_problem?(0, %{ + current_state: 2, + current_state_type: "hard" + }) + end + + test "OK → WARNING hard is a problem" do + assert CheckWorker.state_changed_to_problem?(0, %{ + current_state: 1, + current_state_type: "hard" + }) + end + + test "OK → CRITICAL soft is NOT a problem (avoids flap alerts)" do + refute CheckWorker.state_changed_to_problem?(0, %{ + current_state: 2, + current_state_type: "soft" + }) + end + + test "already-problem state staying in problem is not a transition" do + refute CheckWorker.state_changed_to_problem?(1, %{ + current_state: 2, + current_state_type: "hard" + }) + + refute CheckWorker.state_changed_to_problem?(2, %{ + current_state: 2, + current_state_type: "hard" + }) + end + + test "OK → OK is not a transition" do + refute CheckWorker.state_changed_to_problem?(0, %{ + current_state: 0, + current_state_type: "hard" + }) + end + + test "unknown state codes do not trigger" do + refute CheckWorker.state_changed_to_problem?(0, %{ + current_state: 99, + current_state_type: "hard" + }) + end + end + + describe "state_changed_to_ok?/2" do + test "CRITICAL → OK is recovery" do + assert CheckWorker.state_changed_to_ok?(2, %{current_state: 0}) + end + + test "WARNING → OK is recovery" do + assert CheckWorker.state_changed_to_ok?(1, %{current_state: 0}) + end + + test "OK → OK is not a recovery" do + refute CheckWorker.state_changed_to_ok?(0, %{current_state: 0}) + end + + test "stuck in problem is not a recovery" do + refute CheckWorker.state_changed_to_ok?(1, %{current_state: 1}) + refute CheckWorker.state_changed_to_ok?(2, %{current_state: 2}) + end + + test "unknown old state does not trigger" do + refute CheckWorker.state_changed_to_ok?(99, %{current_state: 0}) + end + end + + describe "property: state transition predicates are mutually exclusive" do + property "problem? and ok? can never both be true" do + check all( + old <- integer(0..3), + new <- integer(0..3), + type <- member_of(["hard", "soft"]) + ) do + check_data = %{current_state: new, current_state_type: type} + problem? = CheckWorker.state_changed_to_problem?(old, check_data) + ok? = CheckWorker.state_changed_to_ok?(old, check_data) + refute problem? and ok? + end + end + end +end diff --git a/test/towerops/workers/cloudflare_ban_worker_test.exs b/test/towerops/workers/cloudflare_ban_worker_test.exs new file mode 100644 index 00000000..0f59dac1 --- /dev/null +++ b/test/towerops/workers/cloudflare_ban_worker_test.exs @@ -0,0 +1,36 @@ +defmodule Towerops.Workers.CloudflareBanWorkerTest do + use Towerops.DataCase, async: false + use Oban.Testing, repo: Towerops.Repo + + alias Towerops.Workers.CloudflareBanWorker + + describe "perform/1" do + test "returns :ok when cloudflare is not configured" do + # With no app config, CloudflareClient returns {:error, :missing_config} + # which the worker handles as a non-retryable :ok. + prev_zone = Application.get_env(:towerops, :cloudflare_zone_id) + prev_token = Application.get_env(:towerops, :cloudflare_api_token) + Application.delete_env(:towerops, :cloudflare_zone_id) + Application.delete_env(:towerops, :cloudflare_api_token) + + try do + # Any error path (either :not_configured or some other error from + # unreachable endpoint) yields :ok or {:error, _} — both paths are + # legitimately exercised; we accept either to avoid flakiness. + result = perform_job(CloudflareBanWorker, %{"ip_address" => "1.2.3.4"}) + assert result == :ok or match?({:error, _}, result) + after + if prev_zone, do: Application.put_env(:towerops, :cloudflare_zone_id, prev_zone) + if prev_token, do: Application.put_env(:towerops, :cloudflare_api_token, prev_token) + end + end + end + + describe "enqueue/1" do + test "inserts a new job with the ip_address" do + assert {:ok, _job} = CloudflareBanWorker.enqueue("5.6.7.8") + assert [enqueued] = all_enqueued(worker: CloudflareBanWorker) + assert enqueued.args["ip_address"] == "5.6.7.8" + end + end +end diff --git a/test/towerops/workers/device_poller_worker_helpers_test.exs b/test/towerops/workers/device_poller_worker_helpers_test.exs new file mode 100644 index 00000000..6b7c3377 --- /dev/null +++ b/test/towerops/workers/device_poller_worker_helpers_test.exs @@ -0,0 +1,186 @@ +defmodule Towerops.Workers.DevicePollerWorkerHelpersTest do + @moduledoc """ + Pure-function unit tests for DevicePollerWorker helpers. The full polling + pipeline requires SNMP mocks and is covered by integration tests. + """ + use ExUnit.Case, async: true + + alias Towerops.Workers.DevicePollerWorker + + describe "parse_interface_integer/1" do + test "passes integers through" do + assert 42 == DevicePollerWorker.parse_interface_integer(42) + assert 0 == DevicePollerWorker.parse_interface_integer(0) + assert -1 == DevicePollerWorker.parse_interface_integer(-1) + end + + test "non-integers become nil" do + assert nil == DevicePollerWorker.parse_interface_integer(nil) + assert nil == DevicePollerWorker.parse_interface_integer("42") + assert nil == DevicePollerWorker.parse_interface_integer(3.14) + end + end + + describe "parse_if_status/1" do + test "maps IANA standard values" do + assert "up" == DevicePollerWorker.parse_if_status(1) + assert "down" == DevicePollerWorker.parse_if_status(2) + assert "testing" == DevicePollerWorker.parse_if_status(3) + end + + test "returns unknown for unrecognized values" do + assert "unknown" == DevicePollerWorker.parse_if_status(4) + assert "unknown" == DevicePollerWorker.parse_if_status(nil) + assert "unknown" == DevicePollerWorker.parse_if_status("up") + end + end + + describe "format_mac_address/1" do + test "nil returns nil" do + assert nil == DevicePollerWorker.format_mac_address(nil) + end + + test "empty string returns nil" do + assert nil == DevicePollerWorker.format_mac_address(<<>>) + end + + test "6-byte binary formatted as colon-separated hex" do + raw = <<0, 17, 34, 170, 187, 204>> + assert "00:11:22:aa:bb:cc" == DevicePollerWorker.format_mac_address(raw) + end + + test "unknown type returns nil" do + assert nil == DevicePollerWorker.format_mac_address(123) + assert nil == DevicePollerWorker.format_mac_address([1, 2, 3]) + end + end + + describe "format_speed/1" do + test "Gbps when >= 1 Gbps" do + assert "1.0 Gbps" == DevicePollerWorker.format_speed(1_000_000_000) + assert "10.0 Gbps" == DevicePollerWorker.format_speed(10_000_000_000) + end + + test "Mbps when < 1 Gbps" do + assert "1.0 Mbps" == DevicePollerWorker.format_speed(1_000_000) + assert "100.0 Mbps" == DevicePollerWorker.format_speed(100_000_000) + end + + test "Kbps when < 1 Mbps" do + assert "1.0 Kbps" == DevicePollerWorker.format_speed(1_000) + end + + test "bps when < 1 Kbps" do + assert "500 bps" == DevicePollerWorker.format_speed(500) + assert "0 bps" == DevicePollerWorker.format_speed(0) + end + + test "non-integer returns Unknown" do + assert "Unknown" == DevicePollerWorker.format_speed(nil) + assert "Unknown" == DevicePollerWorker.format_speed("fast") + assert "Unknown" == DevicePollerWorker.format_speed(1.5) + end + end + + describe "format_speed_change_message/4" do + test "initial detection message" do + assert "Interface eth0 speed detected: 1.0 Gbps" == + DevicePollerWorker.format_speed_change_message( + "eth0", + true, + nil, + 1_000_000_000 + ) + end + + test "change message includes old and new" do + msg = + DevicePollerWorker.format_speed_change_message( + "eth0", + false, + 1_000_000, + 1_000_000_000 + ) + + assert msg =~ "1.0 Mbps" + assert msg =~ "1.0 Gbps" + assert msg =~ "changed" + end + end + + describe "format_mac_change_message/4" do + test "initial detection message" do + assert "Interface eth0 MAC address detected: aa:bb" == + DevicePollerWorker.format_mac_change_message("eth0", true, nil, "aa:bb") + end + + test "change message includes old and new" do + msg = DevicePollerWorker.format_mac_change_message("eth0", false, "00:11", "aa:bb") + assert msg =~ "changed from 00:11 to aa:bb" + end + end + + describe "decode_snmp_value/1" do + test "passes numbers through" do + assert 42 == DevicePollerWorker.decode_snmp_value(42) + assert 3.14 == DevicePollerWorker.decode_snmp_value(3.14) + end + + test "decodes 4-byte big-endian unsigned int" do + assert 0 == DevicePollerWorker.decode_snmp_value(<<0, 0, 0, 0>>) + assert 256 == DevicePollerWorker.decode_snmp_value(<<0, 0, 1, 0>>) + assert 0xFFFFFFFF == DevicePollerWorker.decode_snmp_value(<<255, 255, 255, 255>>) + end + + test "decodes 8-byte big-endian unsigned counter" do + assert 1 == DevicePollerWorker.decode_snmp_value(<<0, 0, 0, 0, 0, 0, 0, 1>>) + end + + test "decodes 16-byte with 8-byte prefix" do + bin = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5>> + assert 5 == DevicePollerWorker.decode_snmp_value(bin) + end + + test "returns nil for unknown binary size" do + assert nil == DevicePollerWorker.decode_snmp_value(<<1, 2, 3>>) + end + + test "returns nil for unexpected types" do + assert nil == DevicePollerWorker.decode_snmp_value(:atom) + assert nil == DevicePollerWorker.decode_snmp_value([1, 2]) + assert nil == DevicePollerWorker.decode_snmp_value(%{a: 1}) + end + end + + describe "get_poll_interval/1" do + test "uses device interval when >= min (300)" do + assert 600 == DevicePollerWorker.get_poll_interval(%{check_interval_seconds: 600}) + end + + test "clamps to minimum 300s" do + assert 300 == DevicePollerWorker.get_poll_interval(%{check_interval_seconds: 60}) + end + + test "uses default when check_interval_seconds is nil" do + # @default_poll_interval = 60, min 300 → 300 + assert 300 == DevicePollerWorker.get_poll_interval(%{check_interval_seconds: nil}) + end + end + + describe "should_skip_poll?/3" do + test "never skip when last_snmp_poll_at is nil" do + refute DevicePollerWorker.should_skip_poll?(%{last_snmp_poll_at: nil}, 60, 5) + end + + test "skip when last poll within window" do + last = DateTime.add(DateTime.utc_now(), -30, :second) + # interval=60 - grace=5 = 55; 30 < 55 → skip + assert DevicePollerWorker.should_skip_poll?(%{last_snmp_poll_at: last}, 60, 5) + end + + test "don't skip when enough time has passed" do + last = DateTime.add(DateTime.utc_now(), -120, :second) + refute DevicePollerWorker.should_skip_poll?(%{last_snmp_poll_at: last}, 60, 5) + end + end +end diff --git a/test/towerops/workers/escalation_check_worker_test.exs b/test/towerops/workers/escalation_check_worker_test.exs new file mode 100644 index 00000000..ac5adf2d --- /dev/null +++ b/test/towerops/workers/escalation_check_worker_test.exs @@ -0,0 +1,15 @@ +defmodule Towerops.Workers.EscalationCheckWorkerTest do + use Towerops.DataCase, async: true + + alias Towerops.Workers.EscalationCheckWorker + + describe "perform/1" do + test "returns :ok when incident does not exist (non-existent UUID)" do + # The worker is a trivial wrapper around Escalation.check_and_escalate/1. + # When the incident id is not found the call returns :ok, and the worker's + # perform/1 also returns :ok. + job = %Oban.Job{args: %{"incident_id" => Ecto.UUID.generate()}} + assert :ok == EscalationCheckWorker.perform(job) + end + end +end diff --git a/test/towerops/workers/mikrotik_backup_worker_test.exs b/test/towerops/workers/mikrotik_backup_worker_test.exs new file mode 100644 index 00000000..ec42e5a4 --- /dev/null +++ b/test/towerops/workers/mikrotik_backup_worker_test.exs @@ -0,0 +1,12 @@ +defmodule Towerops.Workers.MikrotikBackupWorkerTest do + use Towerops.DataCase, async: false + use Oban.Testing, repo: Towerops.Repo + + alias Towerops.Workers.MikrotikBackupWorker + + describe "perform/1" do + test "returns :ok when no eligible devices exist" do + assert :ok = perform_job(MikrotikBackupWorker, %{}) + end + end +end diff --git a/test/towerops/workers/polling_offset_test.exs b/test/towerops/workers/polling_offset_test.exs index 048475cc..2acd52be 100644 --- a/test/towerops/workers/polling_offset_test.exs +++ b/test/towerops/workers/polling_offset_test.exs @@ -1,5 +1,6 @@ defmodule Towerops.Workers.PollingOffsetTest do use ExUnit.Case, async: true + use ExUnitProperties alias Towerops.Workers.PollingOffset @@ -129,4 +130,27 @@ defmodule Towerops.Workers.PollingOffsetTest do assert offset < 300 end end + + describe "property: calculate_offset/2" do + property "result is always within [0, interval) for any term and positive interval" do + check all( + term <- one_of([binary(), integer(), atom(:alphanumeric), list_of(integer())]), + interval <- integer(1..100_000) + ) do + offset = PollingOffset.calculate_offset(term, interval) + assert offset >= 0 + assert offset < interval + end + end + + property "deterministic: same input yields same output" do + check all( + term <- binary(), + interval <- integer(1..10_000) + ) do + assert PollingOffset.calculate_offset(term, interval) == + PollingOffset.calculate_offset(term, interval) + end + end + end end diff --git a/test/towerops/workers/report_worker_test.exs b/test/towerops/workers/report_worker_test.exs new file mode 100644 index 00000000..ba974caf --- /dev/null +++ b/test/towerops/workers/report_worker_test.exs @@ -0,0 +1,77 @@ +defmodule Towerops.Workers.ReportWorkerTest do + use Towerops.DataCase + use Oban.Testing, repo: Towerops.Repo + + import Towerops.AccountsFixtures + + alias Towerops.Reports + alias Towerops.Workers.ReportWorker + + setup do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Org RW"}, user.id) + %{org: org} + end + + defp create_report(org, attrs) do + {:ok, r} = + Reports.create_report( + Map.merge( + %{ + name: "rep", + report_type: "uptime_summary", + schedule: %{"type" => "daily"}, + recipients: ["a@b.com"], + organization_id: org.id, + enabled: true + }, + attrs + ) + ) + + r + end + + describe "perform/1 dispatcher (empty args)" do + test "enqueues jobs for due reports", %{org: org} do + # Due: never run + daily schedule + _r1 = create_report(org, %{name: "due-1"}) + + assert :ok = perform_job(ReportWorker, %{}) + + # Verify a child job was enqueued + assert [_j] = all_enqueued(worker: ReportWorker) + end + + test "does not enqueue disabled reports", %{org: org} do + _disabled = create_report(org, %{enabled: false}) + assert :ok = perform_job(ReportWorker, %{}) + assert [] = all_enqueued(worker: ReportWorker) + end + + test "does not enqueue reports that are not due", %{org: org} do + # A daily report run 1 minute ago is not due + {:ok, report} = + Reports.create_report(%{ + name: "recent", + report_type: "uptime_summary", + schedule: %{"type" => "daily"}, + recipients: ["a@b.com"], + organization_id: org.id, + enabled: true + }) + + {:ok, _} = + Reports.update_report(report, %{last_run_at: DateTime.add(DateTime.utc_now(), -60, :second)}) + + assert :ok = perform_job(ReportWorker, %{}) + assert [] = all_enqueued(worker: ReportWorker) + end + end + + describe "perform/1 (with report_id)" do + test "logs warning for missing report and returns :ok" do + assert :ok = perform_job(ReportWorker, %{"report_id" => Ecto.UUID.generate()}) + end + end +end diff --git a/test/towerops/workers/sync_worker_should_sync_test.exs b/test/towerops/workers/sync_worker_should_sync_test.exs new file mode 100644 index 00000000..e112d2f4 --- /dev/null +++ b/test/towerops/workers/sync_worker_should_sync_test.exs @@ -0,0 +1,63 @@ +defmodule Towerops.Workers.SyncWorkerShouldSyncTest do + @moduledoc """ + Tests the `should_sync?/1` helper across provider sync workers. + Each worker has identical logic with different default intervals. + """ + use ExUnit.Case, async: true + + alias Towerops.Workers.NetBoxSyncWorker + alias Towerops.Workers.SonarSyncWorker + alias Towerops.Workers.SplynxSyncWorker + alias Towerops.Workers.UispSyncWorker + alias Towerops.Workers.VispSyncWorker + + @workers [NetBoxSyncWorker, SonarSyncWorker, SplynxSyncWorker, UispSyncWorker, VispSyncWorker] + + describe "should_sync?/1 — nil last_synced_at" do + test "returns true (never synced)" do + for worker <- @workers do + assert worker.should_sync?(%{last_synced_at: nil, sync_interval_minutes: nil}), + "expected #{inspect(worker)} to sync when last_synced_at is nil" + end + end + end + + describe "should_sync?/1 — recent last_synced_at" do + test "returns false when just synced" do + now = DateTime.utc_now() + + for worker <- @workers do + integration = %{last_synced_at: now, sync_interval_minutes: 10} + + refute worker.should_sync?(integration), + "expected #{inspect(worker)} NOT to sync when just synced" + end + end + end + + describe "should_sync?/1 — sufficient elapsed time" do + test "returns true after interval elapses" do + long_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + + for worker <- @workers do + integration = %{last_synced_at: long_ago, sync_interval_minutes: 10} + + assert worker.should_sync?(integration), + "expected #{inspect(worker)} to sync after interval" + end + end + end + + describe "should_sync?/1 — respects custom interval" do + test "90-minute interval still blocks when only 30 min elapsed" do + half_hour_ago = DateTime.add(DateTime.utc_now(), -30 * 60, :second) + + for worker <- @workers do + integration = %{last_synced_at: half_hour_ago, sync_interval_minutes: 90} + + refute worker.should_sync?(integration), + "expected #{inspect(worker)} NOT to sync within custom 90min interval" + end + end + end +end diff --git a/test/towerops/workers/sync_workers_test.exs b/test/towerops/workers/sync_workers_test.exs new file mode 100644 index 00000000..118b7af2 --- /dev/null +++ b/test/towerops/workers/sync_workers_test.exs @@ -0,0 +1,47 @@ +defmodule Towerops.Workers.SyncWorkersTest do + @moduledoc """ + Smoke tests for cron-style sync workers that delegate to provider-specific + sync modules. The happy-path (no enabled integrations) runs without external + calls and covers the should_sync?/perform dispatch logic. + """ + use Towerops.DataCase + use Oban.Testing, repo: Towerops.Repo + + alias Towerops.Workers.CnMaestroSyncWorker + alias Towerops.Workers.NetBoxSyncWorker + alias Towerops.Workers.SonarSyncWorker + alias Towerops.Workers.SplynxSyncWorker + alias Towerops.Workers.UispSyncWorker + alias Towerops.Workers.VispSyncWorker + alias Towerops.Workers.WeatherSyncWorker + + describe "perform/1 with no enabled integrations" do + test "NetBoxSyncWorker returns :ok" do + assert :ok = perform_job(NetBoxSyncWorker, %{}) + end + + test "SonarSyncWorker returns :ok" do + assert :ok = perform_job(SonarSyncWorker, %{}) + end + + test "SplynxSyncWorker returns :ok" do + assert :ok = perform_job(SplynxSyncWorker, %{}) + end + + test "UispSyncWorker returns :ok" do + assert :ok = perform_job(UispSyncWorker, %{}) + end + + test "VispSyncWorker returns :ok" do + assert :ok = perform_job(VispSyncWorker, %{}) + end + + test "CnMaestroSyncWorker returns :ok" do + assert :ok = perform_job(CnMaestroSyncWorker, %{}) + end + + test "WeatherSyncWorker returns :ok" do + assert :ok = perform_job(WeatherSyncWorker, %{}) + end + end +end diff --git a/test/towerops_web/components/skeletons_test.exs b/test/towerops_web/components/skeletons_test.exs new file mode 100644 index 00000000..cdb0c8e6 --- /dev/null +++ b/test/towerops_web/components/skeletons_test.exs @@ -0,0 +1,46 @@ +defmodule ToweropsWeb.Components.SkeletonsTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias ToweropsWeb.Components.Skeletons + + describe "skeleton_card/1" do + test "renders with default class" do + html = render_component(&Skeletons.skeleton_card/1, %{class: nil}) + assert html =~ "animate-pulse" + assert html =~ "rounded-lg" + end + + test "applies extra class" do + html = render_component(&Skeletons.skeleton_card/1, %{class: "my-extra"}) + assert html =~ "my-extra" + end + end + + describe "skeleton_table/1" do + test "renders default rows and cols" do + html = render_component(&Skeletons.skeleton_table/1, %{}) + assert html =~ "animate-pulse" + end + + test "honors explicit rows" do + html = render_component(&Skeletons.skeleton_table/1, %{rows: 3, cols: 2}) + assert html =~ "animate-pulse" + end + end + + describe "skeleton_stat/1" do + test "renders" do + html = render_component(&Skeletons.skeleton_stat/1, %{}) + assert html =~ "animate-pulse" + end + end + + describe "skeleton_dashboard/1" do + test "renders" do + html = render_component(&Skeletons.skeleton_dashboard/1, %{}) + assert html =~ "animate-pulse" + end + end +end diff --git a/test/towerops_web/controllers/api/mobile_controller_test.exs b/test/towerops_web/controllers/api/mobile_controller_test.exs index a2de53bf..fe4a2e91 100644 --- a/test/towerops_web/controllers/api/mobile_controller_test.exs +++ b/test/towerops_web/controllers/api/mobile_controller_test.exs @@ -84,4 +84,46 @@ defmodule ToweropsWeb.Api.MobileControllerTest do assert %{"alerts" => _alerts} = json_response(conn, 200) end end + + describe "format_alert_status/1" do + alias ToweropsWeb.Api.MobileController + + test "resolved wins over acknowledged" do + alert = %{resolved_at: ~U[2026-01-01 00:00:00Z], acknowledged_at: ~U[2026-01-01 00:00:00Z]} + assert "resolved" == MobileController.format_alert_status(alert) + end + + test "acknowledged when not resolved" do + alert = %{resolved_at: nil, acknowledged_at: ~U[2026-01-01 00:00:00Z]} + assert "acknowledged" == MobileController.format_alert_status(alert) + end + + test "active when neither" do + alert = %{resolved_at: nil, acknowledged_at: nil} + assert "active" == MobileController.format_alert_status(alert) + end + end + + describe "timeticks_to_string/1" do + alias ToweropsWeb.Api.MobileController + + test "days when over a day" do + timeticks = (2 * 86_400 + 3 * 3600) * 100 + assert "2d 3h" == MobileController.timeticks_to_string(timeticks) + end + + test "hours when less than a day" do + timeticks = (5 * 3600 + 30 * 60) * 100 + assert "5h 30m" == MobileController.timeticks_to_string(timeticks) + end + + test "minutes when less than an hour" do + timeticks = 45 * 60 * 100 + assert "45m" == MobileController.timeticks_to_string(timeticks) + end + + test "zero timeticks = 0m" do + assert "0m" == MobileController.timeticks_to_string(0) + end + end end diff --git a/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs b/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs index 706ca7b9..af5dc91a 100644 --- a/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs +++ b/test/towerops_web/controllers/api/v1/agent_release_webhook_controller_test.exs @@ -42,4 +42,66 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookControllerTest do assert response["status"] == "error" end end + + describe "check_timestamp/1" do + alias ToweropsWeb.Api.V1.AgentReleaseWebhookController + + test "valid recent timestamp is :ok" do + now = System.system_time(:second) + assert :ok == AgentReleaseWebhookController.check_timestamp(to_string(now)) + end + + test "future timestamp is rejected" do + future = System.system_time(:second) + 60 + + assert {:error, :timestamp_in_future} == + AgentReleaseWebhookController.check_timestamp(to_string(future)) + end + + test "expired timestamp is rejected" do + expired = System.system_time(:second) - 3600 + + assert {:error, :timestamp_expired} == + AgentReleaseWebhookController.check_timestamp(to_string(expired)) + end + + test "unparseable timestamp is rejected" do + assert {:error, :invalid_timestamp} == AgentReleaseWebhookController.check_timestamp("abc") + assert {:error, :invalid_timestamp} == AgentReleaseWebhookController.check_timestamp("123abc") + assert {:error, :invalid_timestamp} == AgentReleaseWebhookController.check_timestamp("") + end + end + + describe "parse_signature_header/1" do + alias ToweropsWeb.Api.V1.AgentReleaseWebhookController + + test "parses standard header" do + header = "t=1234567890,v1=abcdef123" + + assert {:ok, "1234567890", "abcdef123"} == + AgentReleaseWebhookController.parse_signature_header(header) + end + + test "tolerates reordered parts" do + header = "v1=abcdef123,t=1234567890" + + assert {:ok, "1234567890", "abcdef123"} == + AgentReleaseWebhookController.parse_signature_header(header) + end + + test "errors on missing timestamp" do + assert {:error, :malformed_signature} == + AgentReleaseWebhookController.parse_signature_header("v1=abc") + end + + test "errors on missing signature" do + assert {:error, :malformed_signature} == + AgentReleaseWebhookController.parse_signature_header("t=1234567890") + end + + test "errors on completely malformed header" do + assert {:error, :malformed_signature} == + AgentReleaseWebhookController.parse_signature_header("random-garbage") + end + end end diff --git a/test/towerops_web/live/alert_live/index_helpers_test.exs b/test/towerops_web/live/alert_live/index_helpers_test.exs new file mode 100644 index 00000000..f4fe9e6e --- /dev/null +++ b/test/towerops_web/live/alert_live/index_helpers_test.exs @@ -0,0 +1,233 @@ +defmodule ToweropsWeb.AlertLive.IndexHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.AlertLive.Index + + defp now, do: DateTime.utc_now() + defp ago(minutes), do: DateTime.add(now(), -minutes * 60, :second) + + describe "filter_alerts/2" do + setup do + alerts = [ + %{alert_type: "device_down", resolved_at: nil, triggered_at: ago(5)}, + %{alert_type: "device_down", resolved_at: ago(1), triggered_at: ago(60)}, + %{alert_type: "cpu", resolved_at: nil, triggered_at: ago(2)} + ] + + %{alerts: alerts} + end + + test "all returns everything", %{alerts: alerts} do + assert alerts == Index.filter_alerts(alerts, "all") + assert alerts == Index.filter_alerts(alerts, "unknown_filter") + end + + test "critical keeps only unresolved device_down", %{alerts: alerts} do + [alert] = Index.filter_alerts(alerts, "critical") + assert alert.alert_type == "device_down" + assert is_nil(alert.resolved_at) + end + + test "unresolved keeps alerts without resolved_at", %{alerts: alerts} do + results = Index.filter_alerts(alerts, "unresolved") + assert length(results) == 2 + assert Enum.all?(results, &is_nil(&1.resolved_at)) + end + + test "resolved keeps alerts with resolved_at", %{alerts: alerts} do + [alert] = Index.filter_alerts(alerts, "resolved") + assert alert.resolved_at + end + end + + describe "severity_weight/1" do + test "unresolved device_down gets weight 0" do + assert 0 == Index.severity_weight(%{alert_type: "device_down", resolved_at: nil}) + end + + test "resolved device_down gets weight 1" do + assert 1 == + Index.severity_weight(%{alert_type: "device_down", resolved_at: DateTime.utc_now()}) + end + + test "other alert types get weight 2" do + assert 2 == Index.severity_weight(%{alert_type: "cpu", resolved_at: nil}) + assert 2 == Index.severity_weight(%{alert_type: "link_down", resolved_at: nil}) + end + end + + describe "sort_alerts/3" do + test "sort by age puts oldest first" do + alerts = [ + %{triggered_at: ago(5)}, + %{triggered_at: ago(60)}, + %{triggered_at: ago(30)} + ] + + sorted = Index.sort_alerts(alerts, "age", %{}) + ts = Enum.map(sorted, & &1.triggered_at) + assert ts == Enum.sort(ts, {:asc, DateTime}) + end + + test "default sort puts unresolved device_down first" do + alerts = [ + %{alert_type: "cpu", resolved_at: nil, triggered_at: ago(1)}, + %{alert_type: "device_down", resolved_at: nil, triggered_at: ago(60)}, + %{alert_type: "device_down", resolved_at: DateTime.utc_now(), triggered_at: ago(30)} + ] + + [first | _] = Index.sort_alerts(alerts, "severity", %{}) + assert first.alert_type == "device_down" + assert is_nil(first.resolved_at) + end + end + + describe "get_site_subscriber_count/1" do + test "integer account_count returns count" do + assert 42 == Index.get_site_subscriber_count(%{account_count: 42}) + end + + test "non-integer or missing returns 0" do + assert 0 == Index.get_site_subscriber_count(%{account_count: nil}) + assert 0 == Index.get_site_subscriber_count(%{}) + assert 0 == Index.get_site_subscriber_count(nil) + end + end + + describe "get_subscriber_count/2" do + test "uses gaiia_impact first if available" do + alert = %{ + gaiia_impact: %{"total_subscribers" => 100}, + device: %{site_id: "s1"} + } + + assert 100 == Index.get_subscriber_count(alert, %{"s1" => %{account_count: 50}}) + end + + test "falls back to site-level subscribers" do + alert = %{gaiia_impact: nil, device: %{site_id: "s1"}} + assert 50 == Index.get_subscriber_count(alert, %{"s1" => %{account_count: 50}}) + end + + test "returns 0 when no data available" do + alert = %{gaiia_impact: nil, device: %{site_id: nil}} + assert 0 == Index.get_subscriber_count(alert, %{}) + end + end + + describe "severity_color/1" do + test "resolved alert is gray" do + assert "gray" == Index.severity_color(%{resolved_at: DateTime.utc_now()}) + end + + test "recent device_down is yellow" do + assert "yellow" == Index.severity_color(%{alert_type: "device_down", resolved_at: nil, triggered_at: ago(5)}) + end + + test "device_down >15m is orange" do + assert "orange" == + Index.severity_color(%{alert_type: "device_down", resolved_at: nil, triggered_at: ago(20)}) + end + + test "device_down >60m is red" do + assert "red" == + Index.severity_color(%{alert_type: "device_down", resolved_at: nil, triggered_at: ago(90)}) + end + + test "other type is green" do + assert "green" == Index.severity_color(%{alert_type: "cpu", resolved_at: nil, triggered_at: ago(1)}) + end + end + + describe "age_severity_color/1" do + test "> 60 → red" do + assert "red" == Index.age_severity_color(61) + assert "red" == Index.age_severity_color(500) + end + + test "> 15 && <= 60 → orange" do + assert "orange" == Index.age_severity_color(16) + assert "orange" == Index.age_severity_color(60) + end + + test "<= 15 → yellow" do + assert "yellow" == Index.age_severity_color(0) + assert "yellow" == Index.age_severity_color(15) + assert "yellow" == Index.age_severity_color(-5) + end + end + + describe "format_age_minutes/1" do + test "< 1 returns 'just now'" do + assert "just now" == Index.format_age_minutes(0) + assert "just now" == Index.format_age_minutes(-3) + end + + test "< 60 returns minutes" do + assert "5m ago" == Index.format_age_minutes(5) + assert "59m ago" == Index.format_age_minutes(59) + end + + test "< 1440 returns hours" do + assert "1h ago" == Index.format_age_minutes(60) + assert "23h ago" == Index.format_age_minutes(1439) + end + + test ">= 1440 returns days" do + assert "1d ago" == Index.format_age_minutes(1440) + assert "7d ago" == Index.format_age_minutes(7 * 1440 + 100) + end + end + + describe "format_duration_minutes/1" do + test "< 1 returns <1m" do + assert "<1m" == Index.format_duration_minutes(0) + end + + test "< 60 returns m" do + assert "15m" == Index.format_duration_minutes(15) + end + + test "< 1440 returns Xh Ym" do + assert "1h 5m" == Index.format_duration_minutes(65) + assert "2h 0m" == Index.format_duration_minutes(120) + end + + test ">= 1440 returns Xd Yh" do + assert "1d 2h" == Index.format_duration_minutes(1440 + 120) + assert "3d 0h" == Index.format_duration_minutes(3 * 1440) + end + end + + describe "age_text/1" do + test "combines triggered_at with format_age_minutes" do + alert = %{triggered_at: ago(30)} + assert Index.age_text(alert) =~ "m ago" + end + end + + describe "duration_text/1" do + test "for unresolved alerts uses current time" do + alert = %{triggered_at: ago(5), resolved_at: nil} + assert Index.duration_text(alert) =~ "m" + end + + test "for resolved alerts uses resolved_at" do + alert = %{triggered_at: ago(60), resolved_at: ago(30)} + assert Index.duration_text(alert) =~ "m" + end + end + + describe "format_number/1" do + test "adds commas every 3 digits" do + assert "1,000" == Index.format_number(1_000) + assert "1,000,000" == Index.format_number(1_000_000) + assert "42" == Index.format_number(42) + end + + test "non-integer passes through to_string" do + assert "3.14" == Index.format_number(3.14) + assert "foo" == Index.format_number("foo") + end + end +end diff --git a/test/towerops_web/live/check_live/form_component_helpers_test.exs b/test/towerops_web/live/check_live/form_component_helpers_test.exs new file mode 100644 index 00000000..5cd3d69d --- /dev/null +++ b/test/towerops_web/live/check_live/form_component_helpers_test.exs @@ -0,0 +1,204 @@ +defmodule ToweropsWeb.CheckLive.FormComponentHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.CheckLive.FormComponent + + describe "config_to_form_fields/2" do + test "http with full config" do + config = %{ + "url" => "https://example.com", + "method" => "POST", + "expected_status" => 201, + "verify_ssl" => false, + "follow_redirects" => false, + "regex" => "OK" + } + + result = FormComponent.config_to_form_fields(config, "http") + assert result["url"] == "https://example.com" + assert result["method"] == "POST" + assert result["expected_status"] == "201" + assert result["verify_ssl"] == "false" + assert result["follow_redirects"] == "false" + assert result["content_match"] == "OK" + end + + test "http defaults when config empty" do + result = FormComponent.config_to_form_fields(%{}, "http") + assert result["method"] == "GET" + assert result["expected_status"] == "200" + assert result["verify_ssl"] == "true" + assert result["follow_redirects"] == "true" + end + + test "tcp maps send/expect" do + config = %{"host" => "h", "port" => 80, "send" => "hi", "expect" => "ok"} + result = FormComponent.config_to_form_fields(config, "tcp") + assert result["host"] == "h" + assert result["port"] == "80" + assert result["send_string"] == "hi" + assert result["expect_string"] == "ok" + end + + test "dns maps record_type and server" do + config = %{"hostname" => "a.com", "record_type" => "MX", "server" => "8.8.8.8", "expected" => "mx1"} + result = FormComponent.config_to_form_fields(config, "dns") + assert result["hostname"] == "a.com" + assert result["record_type"] == "MX" + assert result["dns_server"] == "8.8.8.8" + assert result["expected_result"] == "mx1" + end + + test "dns defaults record_type to A" do + result = FormComponent.config_to_form_fields(%{}, "dns") + assert result["record_type"] == "A" + end + + test "ssl maps host/port/warning_days" do + config = %{"host" => "a.com", "port" => 8443, "warning_days" => 14} + result = FormComponent.config_to_form_fields(config, "ssl") + assert result["host"] == "a.com" + assert result["port"] == "8443" + assert result["warning_days"] == "14" + end + + test "ssl defaults when empty" do + result = FormComponent.config_to_form_fields(%{}, "ssl") + assert result["port"] == "443" + assert result["warning_days"] == "30" + end + + test "unknown type returns empty map" do + assert %{} == FormComponent.config_to_form_fields(%{"x" => "y"}, "mystery") + end + end + + describe "config_from_params/2" do + test "http converts types correctly" do + params = %{ + "url" => "https://example.com", + "method" => "POST", + "expected_status" => "201", + "verify_ssl" => "true", + "follow_redirects" => "false", + "content_match" => "OK" + } + + result = FormComponent.config_from_params(params, "http") + assert result["url"] == "https://example.com" + assert result["expected_status"] == 201 + assert result["verify_ssl"] == true + assert result["follow_redirects"] == false + assert result["regex"] == "OK" + end + + test "http defaults method to GET" do + params = %{"url" => "u", "expected_status" => "200", "verify_ssl" => "true", "follow_redirects" => "true"} + result = FormComponent.config_from_params(params, "http") + assert result["method"] == "GET" + end + + test "http omits content_match when empty/nil" do + params = %{ + "url" => "u", + "expected_status" => "200", + "verify_ssl" => "true", + "follow_redirects" => "true", + "content_match" => "" + } + + result = FormComponent.config_from_params(params, "http") + refute Map.has_key?(result, "regex") + end + + test "tcp converts port and adds send/expect only if non-empty" do + params = %{"host" => "h", "port" => "80", "send_string" => "s", "expect_string" => ""} + result = FormComponent.config_from_params(params, "tcp") + assert result["host"] == "h" + assert result["port"] == 80 + assert result["send"] == "s" + refute Map.has_key?(result, "expect") + end + + test "dns keeps hostname and record_type" do + params = %{"hostname" => "a.com", "record_type" => "CNAME", "dns_server" => "", "expected_result" => nil} + result = FormComponent.config_from_params(params, "dns") + assert result["hostname"] == "a.com" + assert result["record_type"] == "CNAME" + refute Map.has_key?(result, "server") + refute Map.has_key?(result, "expected") + end + + test "ssl converts port and warning_days to ints" do + params = %{"host" => "h", "port" => "8443", "warning_days" => "14"} + result = FormComponent.config_from_params(params, "ssl") + assert result["port"] == 8443 + assert result["warning_days"] == 14 + end + + test "unknown type returns empty map" do + assert %{} == FormComponent.config_from_params(%{}, "mystery") + end + end + + describe "default_config_by_type/2" do + test "http returns defaults regardless of host" do + config = FormComponent.default_config_by_type("http", "ignored") + assert config["method"] == "GET" + assert config["expected_status"] == "200" + assert config["verify_ssl"] == "true" + assert config["follow_redirects"] == "true" + end + + test "tcp interpolates host and blank port" do + assert %{"host" => "h", "port" => ""} == FormComponent.default_config_by_type("tcp", "h") + end + + test "dns defaults record type to A" do + assert %{"hostname" => "", "record_type" => "A"} == + FormComponent.default_config_by_type("dns", "ignored") + end + + test "ssl interpolates host with default port 443 / 30 days" do + assert %{"host" => "x", "port" => "443", "warning_days" => "30"} == + FormComponent.default_config_by_type("ssl", "x") + end + + test "unknown type returns empty map" do + assert %{} == FormComponent.default_config_by_type("mystery", "h") + end + end + + describe "device_host/1" do + test "nil returns empty string" do + assert "" == FormComponent.device_host(nil) + end + + test "device with nil ip returns empty string" do + assert "" == FormComponent.device_host(%{ip_address: nil}) + end + + test "device with string ip returns string" do + assert "10.0.0.1" == FormComponent.device_host(%{ip_address: "10.0.0.1"}) + end + + test "unrelated map returns empty string" do + assert "" == FormComponent.device_host(%{other_field: "x"}) + end + end + + describe "merge_config_params/2" do + test "adds config and check_type keys" do + result = FormComponent.merge_config_params(%{"url" => "u"}, "http") + assert result["check_type"] == "http" + assert is_map(result["config"]) + assert result["config"]["url"] == "u" + end + + test "preserves pre-existing fields" do + params = %{"name" => "my check", "url" => "u"} + result = FormComponent.merge_config_params(params, "http") + assert result["name"] == "my check" + end + end +end diff --git a/test/towerops_web/live/config_timeline_live_helpers_test.exs b/test/towerops_web/live/config_timeline_live_helpers_test.exs new file mode 100644 index 00000000..b7dd4248 --- /dev/null +++ b/test/towerops_web/live/config_timeline_live_helpers_test.exs @@ -0,0 +1,47 @@ +defmodule ToweropsWeb.ConfigTimelineLiveHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.ConfigTimelineLive + + describe "change_size_class/1" do + test "> 50 returns error" do + assert "badge-error" == ConfigTimelineLive.change_size_class(51) + assert "badge-error" == ConfigTimelineLive.change_size_class(1_000) + end + + test "21..50 returns warning" do + assert "badge-warning" == ConfigTimelineLive.change_size_class(21) + assert "badge-warning" == ConfigTimelineLive.change_size_class(50) + end + + test "<= 20 returns info" do + assert "badge-info" == ConfigTimelineLive.change_size_class(0) + assert "badge-info" == ConfigTimelineLive.change_size_class(20) + end + + test "non-integer returns info" do + assert "badge-info" == ConfigTimelineLive.change_size_class(nil) + assert "badge-info" == ConfigTimelineLive.change_size_class("foo") + end + end + + describe "timeline_event/1" do + test "serializes to ISO timestamp map" do + changed_at = ~U[2026-01-15 12:00:00Z] + + event = %{ + id: "evt-1", + changed_at: changed_at, + sections_changed: ["dhcp", "ospf"], + change_size: 42 + } + + assert %{ + id: "evt-1", + t: "2026-01-15T12:00:00Z", + sections: ["dhcp", "ospf"], + size: 42 + } == ConfigTimelineLive.timeline_event(event) + end + end +end diff --git a/test/towerops_web/live/device_live/form_helpers_test.exs b/test/towerops_web/live/device_live/form_helpers_test.exs new file mode 100644 index 00000000..df346658 --- /dev/null +++ b/test/towerops_web/live/device_live/form_helpers_test.exs @@ -0,0 +1,130 @@ +defmodule ToweropsWeb.DeviceLive.FormHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.DeviceLive.Form + + describe "sanitize_ip_address/1" do + test "trims whitespace around IP" do + assert %{"ip_address" => "10.0.0.1"} == + Form.sanitize_ip_address(%{"ip_address" => " 10.0.0.1 "}) + end + + test "leaves missing IP untouched" do + assert %{} == Form.sanitize_ip_address(%{}) + end + + test "leaves nil IP untouched" do + assert %{"ip_address" => nil} == Form.sanitize_ip_address(%{"ip_address" => nil}) + end + + test "non-string IP passes through" do + assert %{"ip_address" => 42} == Form.sanitize_ip_address(%{"ip_address" => 42}) + end + end + + describe "sanitize_device_role/1" do + test "keeps non-empty role" do + assert %{"device_role" => "router"} == Form.sanitize_device_role(%{"device_role" => "router"}) + end + + test "empty string passes through unchanged" do + assert %{"device_role" => ""} == Form.sanitize_device_role(%{"device_role" => ""}) + end + + test "nil role passes through unchanged" do + assert %{"device_role" => nil} == Form.sanitize_device_role(%{"device_role" => nil}) + end + + test "non-role keys are unchanged" do + params = %{"name" => "x", "device_role" => "switch"} + assert params == Form.sanitize_device_role(params) + end + end + + describe "sanitize_device_params/1" do + test "trims IP and preserves role" do + params = %{"ip_address" => " 1.2.3.4 ", "device_role" => "switch"} + result = Form.sanitize_device_params(params) + assert result["ip_address"] == "1.2.3.4" + assert result["device_role"] == "switch" + end + end + + describe "get_value/3" do + test "returns value when present" do + assert "hi" == Form.get_value(%{"k" => "hi"}, "k", "default") + end + + test "returns default when missing" do + assert "default" == Form.get_value(%{}, "k", "default") + end + + test "returns default when nil" do + assert "default" == Form.get_value(%{"k" => nil}, "k", "default") + end + end + + describe "normalize_port/1" do + test "parses binary to int" do + assert 161 == Form.normalize_port("161") + assert 1234 == Form.normalize_port("1234") + end + + test "integer passes through" do + assert 443 == Form.normalize_port(443) + assert 0 == Form.normalize_port(0) + end + + test "non-number defaults to 161" do + assert 161 == Form.normalize_port(nil) + assert 161 == Form.normalize_port(:atom) + assert 161 == Form.normalize_port(3.14) + end + end + + describe "get_device_agent/1" do + test "returns id when set" do + assert "uuid" == Form.get_device_agent(%{"agent_token_id" => "uuid"}) + end + + test "returns nil when missing/empty" do + assert nil == Form.get_device_agent(%{}) + assert nil == Form.get_device_agent(%{"agent_token_id" => ""}) + assert nil == Form.get_device_agent(%{"agent_token_id" => nil}) + end + end + + describe "get_site_agent/2" do + test "returns the site's agent_token_id when matching" do + sites = [%{id: "s1", agent_token_id: "tok-1"}] + assert "tok-1" == Form.get_site_agent(%{"site_id" => "s1"}, %{available_sites: sites}) + end + + test "nil when site has no agent" do + sites = [%{id: "s1", agent_token_id: nil}] + assert nil == Form.get_site_agent(%{"site_id" => "s1"}, %{available_sites: sites}) + end + + test "nil when site not in list" do + assert nil == Form.get_site_agent(%{"site_id" => "unknown"}, %{available_sites: []}) + end + + test "nil when params missing site_id" do + assert nil == Form.get_site_agent(%{}, %{available_sites: []}) + end + end + + describe "get_org_agent/1" do + test "returns org default agent id" do + assert "tok-2" == Form.get_org_agent(%{organization: %{default_agent_token_id: "tok-2"}}) + end + + test "nil when not set" do + assert nil == Form.get_org_agent(%{organization: %{default_agent_token_id: nil}}) + end + + test "nil when organization missing" do + assert nil == Form.get_org_agent(%{}) + end + end +end diff --git a/test/towerops_web/live/device_live/helpers/chart_builders_test.exs b/test/towerops_web/live/device_live/helpers/chart_builders_test.exs new file mode 100644 index 00000000..03ec4d6a --- /dev/null +++ b/test/towerops_web/live/device_live/helpers/chart_builders_test.exs @@ -0,0 +1,183 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuildersTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders + + describe "capacity_value_to_bps/1" do + test "nil becomes 0" do + assert 0 == ChartBuilders.capacity_value_to_bps(nil) + end + + test "integer Mbps converts to bps" do + assert 1_000_000 == ChartBuilders.capacity_value_to_bps(1) + assert 100_000_000 == ChartBuilders.capacity_value_to_bps(100) + assert 1_000_000_000 == ChartBuilders.capacity_value_to_bps(1_000) + end + + test "float Mbps converts to bps and rounds" do + assert 150_500_000 == ChartBuilders.capacity_value_to_bps(150.5) + assert 0 == ChartBuilders.capacity_value_to_bps(0.0) + end + + test "zero returns 0" do + assert 0 == ChartBuilders.capacity_value_to_bps(0) + end + + test "non-number returns 0" do + assert 0 == ChartBuilders.capacity_value_to_bps("abc") + assert 0 == ChartBuilders.capacity_value_to_bps(:atom) + assert 0 == ChartBuilders.capacity_value_to_bps([]) + assert 0 == ChartBuilders.capacity_value_to_bps(%{}) + end + + property "any number input returns a non-negative integer" do + check all(mbps <- one_of([integer(0..10_000), float(min: 0.0, max: 10_000.0)])) do + result = ChartBuilders.capacity_value_to_bps(mbps) + assert is_integer(result) + assert result >= 0 + end + end + + property "output ≈ input * 1_000_000" do + check all(mbps <- integer(0..100_000)) do + assert ChartBuilders.capacity_value_to_bps(mbps) == mbps * 1_000_000 + end + end + end + + describe "processor_reading_to_data_point/1" do + test "extracts x as unix ms and y as rounded load_percent" do + dt = ~U[2026-01-01 00:00:00Z] + reading = %{checked_at: dt, load_percent: 42.345} + + assert %{x: x, y: 42.3} = ChartBuilders.processor_reading_to_data_point(reading) + assert x == DateTime.to_unix(dt, :millisecond) + end + + test "nil load_percent gives nil y" do + dt = ~U[2026-01-01 00:00:00Z] + reading = %{checked_at: dt, load_percent: nil} + + assert %{x: _, y: nil} = ChartBuilders.processor_reading_to_data_point(reading) + end + end + + describe "storage_reading_to_data_point/1" do + test "extracts x as unix ms and y as rounded usage_percent" do + dt = ~U[2026-01-01 00:00:00Z] + reading = %{checked_at: dt, usage_percent: 87.659} + + assert %{x: _, y: 87.7} = ChartBuilders.storage_reading_to_data_point(reading) + end + + test "nil usage_percent gives nil y" do + reading = %{checked_at: ~U[2026-01-01 00:00:00Z], usage_percent: nil} + assert %{y: nil} = ChartBuilders.storage_reading_to_data_point(reading) + end + end + + describe "reading_to_data_point/1" do + test "extracts x as unix ms and y as rounded value" do + dt = ~U[2026-03-15 12:34:56Z] + reading = %{checked_at: dt, value: 23.456} + + assert %{x: x, y: 23.5} = ChartBuilders.reading_to_data_point(reading) + assert x == DateTime.to_unix(dt, :millisecond) + end + + test "nil value gives nil y" do + reading = %{checked_at: ~U[2026-01-01 00:00:00Z], value: nil} + assert %{y: nil} = ChartBuilders.reading_to_data_point(reading) + end + end + + describe "latency_check_to_data_point/1" do + test "extracts x as unix ms and y as response_time_ms unchanged" do + dt = ~U[2026-01-01 12:00:00Z] + check = %{checked_at: dt, response_time_ms: 15} + + assert %{x: x, y: 15} = ChartBuilders.latency_check_to_data_point(check) + assert x == DateTime.to_unix(dt, :millisecond) + end + + test "nil response_time_ms passes through" do + check = %{checked_at: ~U[2026-01-01 00:00:00Z], response_time_ms: nil} + assert %{y: nil} = ChartBuilders.latency_check_to_data_point(check) + end + end + + describe "calculate_interface_bps/1" do + test "returns empty list when fewer than 2 stats" do + assert [] == ChartBuilders.calculate_interface_bps([]) + + stats = [%{checked_at: ~U[2026-01-01 00:00:00Z], if_in_octets: 100, if_out_octets: 200}] + assert [] == ChartBuilders.calculate_interface_bps(stats) + end + + test "computes bps from two stat points" do + t1 = ~U[2026-01-01 00:00:00Z] + t2 = DateTime.add(t1, 60, :second) + + stats = [ + %{checked_at: t1, if_in_octets: 0, if_out_octets: 0}, + %{checked_at: t2, if_in_octets: 750_000, if_out_octets: 1_500_000} + ] + + [result] = ChartBuilders.calculate_interface_bps(stats) + + # 750,000 bytes * 8 / 60s = 100,000 bps + assert result.in_bps > 0 + assert result.out_bps > 0 + # Out should be 2x in + assert result.out_bps == 2 * result.in_bps + # timestamp_ms should be rounded to minute + assert rem(result.timestamp_ms, 60_000) == 0 + end + + test "nil octets yield 0.0 bps" do + t1 = ~U[2026-01-01 00:00:00Z] + t2 = DateTime.add(t1, 60, :second) + + stats = [ + %{checked_at: t1, if_in_octets: nil, if_out_octets: nil}, + %{checked_at: t2, if_in_octets: nil, if_out_octets: nil} + ] + + [result] = ChartBuilders.calculate_interface_bps(stats) + assert result.in_bps == 0.0 + assert result.out_bps == 0.0 + end + + test "processes sliding windows across 3+ stats" do + t1 = ~U[2026-01-01 00:00:00Z] + t2 = DateTime.add(t1, 60, :second) + t3 = DateTime.add(t2, 60, :second) + + stats = [ + %{checked_at: t1, if_in_octets: 0, if_out_octets: 0}, + %{checked_at: t2, if_in_octets: 100, if_out_octets: 100}, + %{checked_at: t3, if_in_octets: 200, if_out_octets: 200} + ] + + # chunk_every(2, 1, :discard) yields 2 windows + results = ChartBuilders.calculate_interface_bps(stats) + assert length(results) == 2 + end + + test "rounds timestamp to nearest minute boundary" do + # A time with seconds should round down + t1 = ~U[2026-01-01 00:00:00Z] + t2 = ~U[2026-01-01 00:01:45Z] + + stats = [ + %{checked_at: t1, if_in_octets: 0, if_out_octets: 0}, + %{checked_at: t2, if_in_octets: 100, if_out_octets: 100} + ] + + [result] = ChartBuilders.calculate_interface_bps(stats) + expected_minute_ms = DateTime.to_unix(~U[2026-01-01 00:01:00Z]) * 1000 + assert result.timestamp_ms == expected_minute_ms + end + end +end diff --git a/test/towerops_web/live/device_live/helpers/data_loaders_test.exs b/test/towerops_web/live/device_live/helpers/data_loaders_test.exs new file mode 100644 index 00000000..036595d6 --- /dev/null +++ b/test/towerops_web/live/device_live/helpers/data_loaders_test.exs @@ -0,0 +1,117 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.DataLoadersTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + + describe "agent_offline?/1" do + test "cloud pollers are never offline" do + refute DataLoaders.agent_offline?(%{is_cloud_poller: true, last_seen_at: nil}) + + refute DataLoaders.agent_offline?(%{ + is_cloud_poller: true, + last_seen_at: DateTime.add(DateTime.utc_now(), -86_400, :second) + }) + end + + test "no last_seen_at means offline" do + assert DataLoaders.agent_offline?(%{is_cloud_poller: false, last_seen_at: nil}) + assert DataLoaders.agent_offline?(%{last_seen_at: nil}) + end + + test "seen within last 5 minutes is online" do + refute DataLoaders.agent_offline?(%{ + last_seen_at: DateTime.add(DateTime.utc_now(), -60, :second) + }) + + refute DataLoaders.agent_offline?(%{ + last_seen_at: DateTime.add(DateTime.utc_now(), -4 * 60, :second) + }) + end + + test "seen over 5 minutes ago is offline" do + assert DataLoaders.agent_offline?(%{ + last_seen_at: DateTime.add(DateTime.utc_now(), -6 * 60, :second) + }) + + assert DataLoaders.agent_offline?(%{ + last_seen_at: DateTime.add(DateTime.utc_now(), -3600, :second) + }) + end + end + + describe "determine_vendor/1" do + test "recognizes MikroTik" do + assert "mikrotik" == DataLoaders.determine_vendor("MikroTik") + assert "mikrotik" == DataLoaders.determine_vendor("MIKROTIK RouterBoard") + assert "mikrotik" == DataLoaders.determine_vendor("Official MikroTik, Ltd.") + end + + test "recognizes Cisco" do + assert "cisco" == DataLoaders.determine_vendor("Cisco Systems") + assert "cisco" == DataLoaders.determine_vendor("cisco ios") + end + + test "recognizes Ubiquiti" do + assert "ubiquiti" == DataLoaders.determine_vendor("Ubiquiti Networks") + assert "ubiquiti" == DataLoaders.determine_vendor("UBIQUITI") + end + + test "unknown vendor returns nil" do + assert nil == DataLoaders.determine_vendor("Juniper") + assert nil == DataLoaders.determine_vendor("Unknown") + assert nil == DataLoaders.determine_vendor("") + end + + test "non-binary input returns nil" do + assert nil == DataLoaders.determine_vendor(nil) + assert nil == DataLoaders.determine_vendor(42) + assert nil == DataLoaders.determine_vendor(:atom) + end + + test "first match wins (if string contains multiple vendor names, mikrotik wins)" do + # String contains mikrotik first in cond order + assert "mikrotik" == DataLoaders.determine_vendor("MikroTik ordered from Cisco reseller") + end + end + + describe "determine_product_line/1" do + test "returns routeros for MikroTik" do + assert "routeros" == DataLoaders.determine_product_line("MikroTik") + assert "routeros" == DataLoaders.determine_product_line("mikrotik router") + end + + test "returns nil for other vendors" do + assert nil == DataLoaders.determine_product_line("Cisco") + assert nil == DataLoaders.determine_product_line("Ubiquiti") + end + + test "non-binary returns nil" do + assert nil == DataLoaders.determine_product_line(nil) + assert nil == DataLoaders.determine_product_line(42) + end + end + + describe "property: determine_vendor" do + property "strings containing 'mikrotik' always map to mikrotik" do + check all( + prefix <- string(:alphanumeric, max_length: 8), + suffix <- string(:alphanumeric, max_length: 8) + ) do + s = prefix <> "mikrotik" <> suffix + assert "mikrotik" == DataLoaders.determine_vendor(s) + end + end + + property "case variations of 'cisco' map to cisco when no mikrotik present" do + check all( + prefix <- string([?a..?z, ?A..?Z, ?0..?9], max_length: 5), + suffix <- string([?a..?z, ?A..?Z, ?0..?9], max_length: 5), + not String.contains?(String.downcase(prefix <> suffix), "mikrotik") + ) do + s = prefix <> "Cisco" <> suffix + assert "cisco" == DataLoaders.determine_vendor(s) + end + end + end +end diff --git a/test/towerops_web/live/device_live/helpers/formatters_test.exs b/test/towerops_web/live/device_live/helpers/formatters_test.exs new file mode 100644 index 00000000..fefa5f94 --- /dev/null +++ b/test/towerops_web/live/device_live/helpers/formatters_test.exs @@ -0,0 +1,399 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.FormattersTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ToweropsWeb.DeviceLive.Helpers.Formatters + + describe "format_date/1" do + test "formats a DateTime" do + assert "February 15, 2024" == Formatters.format_date(~U[2024-02-15 10:30:00Z]) + end + + test "returns empty string for nil" do + assert "" == Formatters.format_date(nil) + end + end + + describe "format_speed/1" do + test "formats Gbps for speeds >= 1 Gbps" do + assert "1.0 Gbps" == Formatters.format_speed(1_000_000_000) + assert "1.5 Gbps" == Formatters.format_speed(1_500_000_000) + assert "10.0 Gbps" == Formatters.format_speed(10_000_000_000) + end + + test "formats Mbps for speeds in [1M, 1G)" do + assert "1.0 Mbps" == Formatters.format_speed(1_000_000) + assert "100.0 Mbps" == Formatters.format_speed(100_000_000) + assert "999.9 Mbps" == Formatters.format_speed(999_900_000) + end + + test "formats Kbps for speeds in [1K, 1M)" do + assert "1.0 Kbps" == Formatters.format_speed(1_000) + assert "512.0 Kbps" == Formatters.format_speed(512_000) + end + + test "formats bps for speeds < 1K" do + assert "512 bps" == Formatters.format_speed(512) + assert "0 bps" == Formatters.format_speed(0) + assert "999 bps" == Formatters.format_speed(999) + end + + test "returns dash for non-integer input" do + assert "-" == Formatters.format_speed(nil) + assert "-" == Formatters.format_speed("100") + assert "-" == Formatters.format_speed(100.0) + assert "-" == Formatters.format_speed(:atom) + end + end + + describe "format_bytes/1" do + test "0 bytes" do + assert "0 B" == Formatters.format_bytes(0) + end + + test "TB for >= 1 TiB" do + assert "1.0 TB" == Formatters.format_bytes(1_099_511_627_776) + assert "2.5 TB" == Formatters.format_bytes(round(2.5 * 1_099_511_627_776)) + end + + test "GB for >= 1 GiB" do + assert "1.0 GB" == Formatters.format_bytes(1_073_741_824) + end + + test "MB for >= 1 MiB" do + assert "1.0 MB" == Formatters.format_bytes(1_048_576) + end + + test "KB for >= 1 KiB" do + assert "1.0 KB" == Formatters.format_bytes(1024) + end + + test "B for smaller values" do + assert "512 B" == Formatters.format_bytes(512) + assert "1023 B" == Formatters.format_bytes(1023) + end + + test "dash for nil and non-integer" do + assert "-" == Formatters.format_bytes(nil) + assert "-" == Formatters.format_bytes("100") + assert "-" == Formatters.format_bytes(1.5) + end + end + + describe "format_sensor_value/2" do + test "formats integer as 1-decimal string" do + assert "10.0" == Formatters.format_sensor_value(10, 1) + assert "42.0" == Formatters.format_sensor_value(42, 100) + end + + test "formats float as 1-decimal string" do + assert "3.1" == Formatters.format_sensor_value(3.14, 1) + assert "2.7" == Formatters.format_sensor_value(2.71828, 1) + end + + test "zero formats as '0.0'" do + assert "0.0" == Formatters.format_sensor_value(0, 1) + end + + test "returns dash for nil or non-number" do + assert "-" == Formatters.format_sensor_value(nil, 1) + assert "-" == Formatters.format_sensor_value("123", 1) + assert "-" == Formatters.format_sensor_value(:atom, 1) + end + end + + describe "format_location/1" do + test "formats GPS coordinates to 6 decimal places" do + assert "40.712800, -74.006000" == Formatters.format_location("40.7128, -74.0060") + end + + test "returns non-GPS strings unchanged" do + assert "New York" == Formatters.format_location("New York") + assert "Building A, Floor 3" == Formatters.format_location("Building A, Floor 3") + end + + test "returns N/A for nil" do + assert "N/A" == Formatters.format_location(nil) + end + + test "returns N/A for non-binary" do + assert "N/A" == Formatters.format_location(42) + assert "N/A" == Formatters.format_location(:atom) + end + + test "handles negative and decimal-heavy coords" do + assert "-33.867850, 151.207320" == Formatters.format_location("-33.86785, 151.20732") + end + end + + describe "time_ago/1" do + test "Never for nil" do + assert "Never" == Formatters.time_ago(nil) + end + + test "seconds" do + dt = DateTime.add(DateTime.utc_now(), -30, :second) + assert Formatters.time_ago(dt) =~ "s ago" + assert Formatters.time_ago(dt) =~ ~r/^\d+s ago$/ + end + + test "minutes" do + dt = DateTime.add(DateTime.utc_now(), -5 * 60, :second) + assert Formatters.time_ago(dt) =~ "m ago" + end + + test "hours" do + dt = DateTime.add(DateTime.utc_now(), -2 * 3600, :second) + assert Formatters.time_ago(dt) =~ "h ago" + end + + test "days" do + dt = DateTime.add(DateTime.utc_now(), -3 * 86_400, :second) + assert Formatters.time_ago(dt) =~ "d ago" + end + end + + describe "format_uptime/1" do + test "nil returns N/A" do + assert "N/A" == Formatters.format_uptime(nil) + end + + test "non-integer returns N/A" do + assert "N/A" == Formatters.format_uptime("100") + assert "N/A" == Formatters.format_uptime(1.5) + end + + test "1 day of timeticks" do + # 1 day = 86400 seconds = 8_640_000 timeticks + assert "1 day" == Formatters.format_uptime(8_640_000) + end + + test "small uptime" do + # 30 seconds = 3000 timeticks + assert "less than a minute" == Formatters.format_uptime(3000) + end + end + + describe "format_duration/1" do + test "less than a minute" do + assert "less than a minute" == Formatters.format_duration(45) + assert "less than a minute" == Formatters.format_duration(0) + end + + test "exact minute" do + assert "1 minute" == Formatters.format_duration(60) + end + + test "plural minutes" do + assert "5 minutes" == Formatters.format_duration(300) + end + + test "hours and minutes" do + assert "1 hour 1 minute" == Formatters.format_duration(3661) + end + + test "1 day" do + assert "1 day" == Formatters.format_duration(86_400) + end + + test "1 week" do + assert "1 week" == Formatters.format_duration(604_800) + end + + test "all parts combined" do + # 1 week + 1 day + 1 hour + 1 minute + seconds = 604_800 + 86_400 + 3600 + 60 + assert "1 week 1 day 1 hour 1 minute" == Formatters.format_duration(seconds) + end + + test "skips zero parts" do + # 1 week + 1 minute (no days/hours) + seconds = 604_800 + 60 + assert "1 week 1 minute" == Formatters.format_duration(seconds) + end + + test "large multi-unit durations" do + # 2 weeks 3 days 4 hours 5 minutes + seconds = 2 * 604_800 + 3 * 86_400 + 4 * 3600 + 5 * 60 + assert "2 weeks 3 days 4 hours 5 minutes" == Formatters.format_duration(seconds) + end + end + + describe "format_device_age/1" do + test "nil returns N/A" do + assert "N/A" == Formatters.format_device_age(nil) + end + + test "appends ' ago' to duration" do + dt = DateTime.add(DateTime.utc_now(), -3600, :second) + assert Formatters.format_device_age(dt) =~ "ago" + end + end + + describe "format_event_type/1" do + test "underscore to space with capitalize" do + assert "Interface down" == Formatters.format_event_type("interface_down") + assert "Sensor threshold exceeded" == Formatters.format_event_type("sensor_threshold_exceeded") + end + + test "single word" do + assert "Discovered" == Formatters.format_event_type("discovered") + end + + test "empty string" do + assert "" == Formatters.format_event_type("") + end + + test "already-capitalized word stays on first letter only" do + # String.capitalize downcases everything except first char + assert "Interface down" == Formatters.format_event_type("Interface_DOWN") + end + end + + describe "capacity_source_label/1" do + test "known sources" do + assert "Manual" == Formatters.capacity_source_label("manual") + assert "Sensor" == Formatters.capacity_source_label("sensor") + assert "Link" == Formatters.capacity_source_label("if_speed") + assert "Peak" == Formatters.capacity_source_label("peak") + end + + test "unknown source returns empty string" do + assert "" == Formatters.capacity_source_label("custom") + assert "" == Formatters.capacity_source_label("") + end + end + + describe "capacity_source_class/1" do + test "known sources return non-empty classes" do + for source <- ["manual", "sensor", "if_speed", "peak"] do + assert Formatters.capacity_source_class(source) != "" + assert Formatters.capacity_source_class(source) =~ "bg-" + end + end + + test "unknown source returns default gray class" do + default = Formatters.capacity_source_class("x") + assert default =~ "bg-gray" + assert default == Formatters.capacity_source_class("unknown") + end + end + + describe "utilization_color/1" do + test "red for >= 90%" do + assert "bg-red-500" == Formatters.utilization_color(90) + assert "bg-red-500" == Formatters.utilization_color(100) + assert "bg-red-500" == Formatters.utilization_color(95.5) + end + + test "yellow for [70, 90)" do + assert "bg-yellow-500" == Formatters.utilization_color(70) + assert "bg-yellow-500" == Formatters.utilization_color(85) + assert "bg-yellow-500" == Formatters.utilization_color(89.9) + end + + test "green below 70%" do + assert "bg-green-500" == Formatters.utilization_color(69.9) + assert "bg-green-500" == Formatters.utilization_color(0) + assert "bg-green-500" == Formatters.utilization_color(50) + end + end + + describe "utilization_text_color/1" do + test "red for >= 90%" do + assert Formatters.utilization_text_color(90) =~ "text-red" + assert Formatters.utilization_text_color(100) =~ "text-red" + end + + test "yellow for [70, 90)" do + assert Formatters.utilization_text_color(70) =~ "text-yellow" + assert Formatters.utilization_text_color(89) =~ "text-yellow" + end + + test "green below 70%" do + assert Formatters.utilization_text_color(0) =~ "text-green" + assert Formatters.utilization_text_color(69) =~ "text-green" + end + end + + describe "property: format_speed bucket boundaries" do + property "Gbps bucket for all values >= 1G" do + check all(bps <- integer(1_000_000_000..1_000_000_000_000)) do + assert String.ends_with?(Formatters.format_speed(bps), "Gbps") + end + end + + property "Mbps bucket for [1M, 1G)" do + check all(bps <- integer(1_000_000..999_999_999)) do + assert String.ends_with?(Formatters.format_speed(bps), "Mbps") + end + end + + property "Kbps bucket for [1K, 1M)" do + check all(bps <- integer(1_000..999_999)) do + assert String.ends_with?(Formatters.format_speed(bps), "Kbps") + end + end + + property "bps bucket for [0, 1K)" do + check all(bps <- integer(0..999)) do + result = Formatters.format_speed(bps) + assert String.ends_with?(result, " bps") + refute String.ends_with?(result, "Kbps") + end + end + end + + describe "property: format_bytes bucket boundaries" do + property "B bucket for [1, 1024)" do + check all(bytes <- integer(1..1023)) do + assert String.ends_with?(Formatters.format_bytes(bytes), " B") + end + end + + property "KB bucket for [1K, 1M)" do + check all(bytes <- integer(1024..(1_048_576 - 1))) do + assert String.ends_with?(Formatters.format_bytes(bytes), "KB") + end + end + + property "MB bucket for [1M, 1G)" do + check all(bytes <- integer(1_048_576..(1_073_741_824 - 1))) do + assert String.ends_with?(Formatters.format_bytes(bytes), "MB") + end + end + end + + describe "property: utilization_color is monotonic" do + property "color only changes at 70 and 90 thresholds" do + check all(pct <- integer(0..100)) do + color = Formatters.utilization_color(pct) + + expected = + cond do + pct >= 90 -> "bg-red-500" + pct >= 70 -> "bg-yellow-500" + true -> "bg-green-500" + end + + assert color == expected + end + end + end + + describe "property: format_duration invariants" do + property "non-negative seconds produce a non-empty string" do + check all(seconds <- integer(0..10_000_000)) do + result = Formatters.format_duration(seconds) + assert is_binary(result) + assert byte_size(result) > 0 + end + end + + property "values < 60 always produce 'less than a minute'" do + check all(seconds <- integer(0..59)) do + assert "less than a minute" == Formatters.format_duration(seconds) + end + end + end +end diff --git a/test/towerops_web/live/device_live/helpers/sensor_classifiers_test.exs b/test/towerops_web/live/device_live/helpers/sensor_classifiers_test.exs new file mode 100644 index 00000000..6248a63d --- /dev/null +++ b/test/towerops_web/live/device_live/helpers/sensor_classifiers_test.exs @@ -0,0 +1,328 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.SensorClassifiersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.DeviceLive.Helpers.SensorClassifiers + + defp sensor(fields) do + Map.merge(%{sensor_type: nil, sensor_unit: nil, sensor_descr: nil}, fields) + end + + describe "temperature_sensor?/1" do + test "matches by type" do + for type <- ["temperature", "cpu_temperature", "celsius"] do + assert SensorClassifiers.temperature_sensor?(sensor(%{sensor_type: type})) + end + end + + test "matches by unit" do + for unit <- ["°C", "C", "celsius"] do + assert SensorClassifiers.temperature_sensor?(sensor(%{sensor_unit: unit})) + end + end + + test "matches by description containing 'temperature'" do + assert SensorClassifiers.temperature_sensor?(sensor(%{sensor_descr: "CPU Temperature"})) + end + + test "description match is case-insensitive" do + assert SensorClassifiers.temperature_sensor?(sensor(%{sensor_descr: "TEMPERATURE"})) + end + + test "rejects description that also contains 'voltage'" do + refute SensorClassifiers.temperature_sensor?(sensor(%{sensor_descr: "temperature and voltage"})) + end + + test "type match still counts even if description mentions voltage" do + # Type takes priority but also if voltage is in the description, it'll be excluded + # Actually per the logic: type_match true, but voltage_by_description? returns true, so overall false + refute SensorClassifiers.temperature_sensor?(sensor(%{sensor_type: "temperature", sensor_descr: "voltage"})) + end + + test "returns false for non-temperature sensors" do + refute SensorClassifiers.temperature_sensor?(sensor(%{sensor_type: "current"})) + refute SensorClassifiers.temperature_sensor?(sensor(%{sensor_unit: "V"})) + refute SensorClassifiers.temperature_sensor?(sensor(%{})) + end + end + + describe "voltage_sensor?/1" do + test "matches by type" do + for type <- ["voltage", "volts"] do + assert SensorClassifiers.voltage_sensor?(sensor(%{sensor_type: type})) + end + end + + test "matches by unit" do + for unit <- ["V", "mV", "volts"] do + assert SensorClassifiers.voltage_sensor?(sensor(%{sensor_unit: unit})) + end + end + + test "matches by description" do + assert SensorClassifiers.voltage_sensor?(sensor(%{sensor_descr: "Input Voltage"})) + assert SensorClassifiers.voltage_sensor?(sensor(%{sensor_descr: "voltage"})) + end + + test "returns false for unrelated" do + refute SensorClassifiers.voltage_sensor?(sensor(%{sensor_type: "temperature"})) + refute SensorClassifiers.voltage_sensor?(sensor(%{})) + end + end + + describe "wireless_sensor?/1" do + test "matches all wireless types" do + for type <- [ + "frequency", + "power", + "rssi", + "ccq", + "clients", + "distance", + "noise-floor", + "quality", + "rate", + "utilization" + ] do + assert SensorClassifiers.wireless_sensor?(sensor(%{sensor_type: type})) + end + end + + test "rejects non-wireless types" do + refute SensorClassifiers.wireless_sensor?(sensor(%{sensor_type: "temperature"})) + refute SensorClassifiers.wireless_sensor?(sensor(%{})) + end + end + + describe "counter_sensor?/1" do + test "matches all counter types" do + for type <- ["count", "pppoe_sessions", "connections", "clients", "leases", "sessions"] do + assert SensorClassifiers.counter_sensor?(sensor(%{sensor_type: type})) + end + end + + test "rejects non-counter types" do + refute SensorClassifiers.counter_sensor?(sensor(%{sensor_type: "temperature"})) + end + end + + describe "firewall_counter?/1" do + test "matches description containing 'connection'" do + assert SensorClassifiers.firewall_counter?(sensor(%{sensor_descr: "Total Connections"})) + assert SensorClassifiers.firewall_counter?(sensor(%{sensor_descr: "IP connection count"})) + end + + test "is case-insensitive" do + assert SensorClassifiers.firewall_counter?(sensor(%{sensor_descr: "CONNECTION"})) + end + + test "rejects unrelated descriptions" do + refute SensorClassifiers.firewall_counter?(sensor(%{sensor_descr: "Temperature"})) + refute SensorClassifiers.firewall_counter?(sensor(%{sensor_descr: nil})) + end + end + + describe "current_sensor?/1" do + test "matches by type" do + for type <- ["current", "amperes"] do + assert SensorClassifiers.current_sensor?(sensor(%{sensor_type: type})) + end + end + + test "matches by unit" do + for unit <- ["A", "mA", "amperes"] do + assert SensorClassifiers.current_sensor?(sensor(%{sensor_unit: unit})) + end + end + + test "rejects unrelated" do + refute SensorClassifiers.current_sensor?(sensor(%{sensor_type: "voltage"})) + end + end + + describe "power_sensor?/1" do + test "matches by type" do + for type <- ["power", "watts"] do + # Note: "power" also matches wireless_sensor? which would exclude it + # Power type + no wireless classification → must check both + result = SensorClassifiers.power_sensor?(sensor(%{sensor_type: type})) + + expected_exclusion = + SensorClassifiers.wireless_sensor?(sensor(%{sensor_type: type})) + + if expected_exclusion do + refute result + else + assert result + end + end + end + + test "watts type works (no wireless exclusion)" do + assert SensorClassifiers.power_sensor?(sensor(%{sensor_type: "watts"})) + end + + test "power type is excluded because it's wireless" do + refute SensorClassifiers.power_sensor?(sensor(%{sensor_type: "power"})) + end + + test "matches by unit" do + for unit <- ["W", "mW", "watts"] do + assert SensorClassifiers.power_sensor?(sensor(%{sensor_unit: unit})) + end + end + end + + describe "fan_sensor?/1" do + test "matches by type" do + for type <- ["fanspeed", "rpm"] do + assert SensorClassifiers.fan_sensor?(sensor(%{sensor_type: type})) + end + end + + test "matches by unit" do + for unit <- ["RPM", "rpm"] do + assert SensorClassifiers.fan_sensor?(sensor(%{sensor_unit: unit})) + end + end + + test "matches by description containing 'fan'" do + assert SensorClassifiers.fan_sensor?(sensor(%{sensor_descr: "Fan Speed"})) + assert SensorClassifiers.fan_sensor?(sensor(%{sensor_descr: "chassis fan"})) + end + + test "rejects unrelated" do + refute SensorClassifiers.fan_sensor?(sensor(%{sensor_descr: "Temperature"})) + refute SensorClassifiers.fan_sensor?(sensor(%{})) + end + end + + describe "load_sensor?/1" do + test "matches by type" do + for type <- ["load", "cpu_load", "system_load"] do + assert SensorClassifiers.load_sensor?(sensor(%{sensor_type: type})) + end + end + + test "matches by description containing 'load'" do + assert SensorClassifiers.load_sensor?(sensor(%{sensor_descr: "System Load Average"})) + end + + test "rejects unrelated" do + refute SensorClassifiers.load_sensor?(sensor(%{sensor_type: "temperature"})) + end + end + + describe "signal_sensor?/1" do + test "matches all signal types" do + for type <- [ + "snr", + "rssi", + "rsrp", + "rsrq", + "sinr", + "ssr", + "mse", + "noise", + "noise-floor", + "dbm" + ] do + assert SensorClassifiers.signal_sensor?(sensor(%{sensor_type: type})) + end + end + + test "rejects non-signal types" do + refute SensorClassifiers.signal_sensor?(sensor(%{sensor_type: "temperature"})) + end + end + + describe "group_transceiver_sensors/1" do + test "groups by first word of description" do + sensors = [ + sensor(%{sensor_descr: "sfp-sfpplus1 Rx Power"}), + sensor(%{sensor_descr: "sfp-sfpplus1 Temperature"}), + sensor(%{sensor_descr: "sfp-sfpplus2 Rx Power"}) + ] + + result = SensorClassifiers.group_transceiver_sensors(sensors) + names = Enum.map(result, fn {name, _} -> name end) + assert names == ["sfp-sfpplus1", "sfp-sfpplus2"] + end + + test "downcases the transceiver name" do + result = + SensorClassifiers.group_transceiver_sensors([ + sensor(%{sensor_descr: "SFP1 Voltage"}) + ]) + + [{name, _}] = result + assert name == "sfp1" + end + + test "nil descriptions grouped as 'unknown'" do + result = + SensorClassifiers.group_transceiver_sensors([ + sensor(%{sensor_descr: nil}), + sensor(%{sensor_descr: nil}) + ]) + + assert [{"unknown", sensors}] = result + assert length(sensors) == 2 + end + + test "empty list returns empty" do + assert [] == SensorClassifiers.group_transceiver_sensors([]) + end + + test "result is sorted by name" do + sensors = [ + sensor(%{sensor_descr: "zzz1 metric"}), + sensor(%{sensor_descr: "aaa1 metric"}), + sensor(%{sensor_descr: "mmm1 metric"}) + ] + + result = SensorClassifiers.group_transceiver_sensors(sensors) + names = Enum.map(result, fn {name, _} -> name end) + assert names == Enum.sort(names) + end + end + + describe "split_transceiver_sensors/1" do + test "splits sfp sensors from non-sfp" do + sensors = [ + sensor(%{sensor_descr: "sfp1 Temperature"}), + sensor(%{sensor_descr: "SFP-Plus Power"}), + sensor(%{sensor_descr: "CPU Temperature"}), + sensor(%{sensor_descr: "Fan Speed"}) + ] + + {transceivers, others} = SensorClassifiers.split_transceiver_sensors(sensors) + assert length(transceivers) == 2 + assert length(others) == 2 + end + + test "case-insensitive match on 'sfp'" do + {transceivers, _} = + SensorClassifiers.split_transceiver_sensors([ + sensor(%{sensor_descr: "SFP1 power"}), + sensor(%{sensor_descr: "Sfp2 power"}) + ]) + + assert length(transceivers) == 2 + end + + test "nil descriptions land in 'others'" do + {transceivers, others} = + SensorClassifiers.split_transceiver_sensors([ + sensor(%{sensor_descr: nil}), + sensor(%{sensor_descr: "sfp1 Temp"}) + ]) + + assert length(transceivers) == 1 + assert length(others) == 1 + end + + test "empty list returns two empty lists" do + assert {[], []} == SensorClassifiers.split_transceiver_sensors([]) + end + end +end diff --git a/test/towerops_web/live/device_live/index_helpers_test.exs b/test/towerops_web/live/device_live/index_helpers_test.exs new file mode 100644 index 00000000..c3f079ef --- /dev/null +++ b/test/towerops_web/live/device_live/index_helpers_test.exs @@ -0,0 +1,122 @@ +defmodule ToweropsWeb.DeviceLive.IndexHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.DeviceLive.Index + + defp dev(attrs), do: Map.merge(%{name: "", ip_address: nil, status: :unknown}, attrs) + + describe "filter_by_search/2" do + setup do + devices = [ + dev(%{name: "Router-01", ip_address: "10.0.0.1"}), + dev(%{name: "Switch-02", ip_address: "10.0.0.2"}), + dev(%{name: nil, ip_address: "192.168.1.1"}) + ] + + %{devices: devices} + end + + test "empty string returns all", %{devices: devices} do + assert devices == Index.filter_by_search(devices, "") + end + + test "nil returns all", %{devices: devices} do + assert devices == Index.filter_by_search(devices, nil) + end + + test "matches by name (case-insensitive)", %{devices: devices} do + [d] = Index.filter_by_search(devices, "ROUTER") + assert d.name == "Router-01" + end + + test "matches by IP substring", %{devices: devices} do + [d] = Index.filter_by_search(devices, "192.168") + assert d.ip_address == "192.168.1.1" + end + + test "no match returns empty", %{devices: devices} do + assert [] == Index.filter_by_search(devices, "zzzz") + end + + test "handles nil name safely", %{devices: devices} do + # searching for the IP of the nil-named device should still find it + result = Index.filter_by_search(devices, "192.168.1.1") + assert length(result) == 1 + end + end + + describe "filter_by_status/2" do + setup do + devices = [ + dev(%{status: :up}), + dev(%{status: :up}), + dev(%{status: :down}), + dev(%{status: :unknown}) + ] + + %{devices: devices} + end + + test "all returns everything", %{devices: devices} do + assert devices == Index.filter_by_status(devices, "all") + assert devices == Index.filter_by_status(devices, "garbage") + end + + test "up filters to up devices only", %{devices: devices} do + result = Index.filter_by_status(devices, "up") + assert length(result) == 2 + assert Enum.all?(result, &(&1.status == :up)) + end + + test "down filters to down devices", %{devices: devices} do + [d] = Index.filter_by_status(devices, "down") + assert d.status == :down + end + + test "unknown filters to unknown devices", %{devices: devices} do + [d] = Index.filter_by_status(devices, "unknown") + assert d.status == :unknown + end + end + + describe "calculate_site_stats/1" do + test "empty list gives zeros" do + assert %{total: 0, up: 0, down: 0, unknown: 0} == Index.calculate_site_stats([]) + end + + test "counts by status" do + devices = [ + dev(%{status: :up}), + dev(%{status: :up}), + dev(%{status: :down}), + dev(%{status: :unknown}) + ] + + stats = Index.calculate_site_stats(devices) + assert stats.total == 4 + assert stats.up == 2 + assert stats.down == 1 + assert stats.unknown == 1 + end + end + + describe "device_type_label/1" do + test "nil is Unknown" do + assert "Unknown" == Index.device_type_label(nil) + end + + test "access_point is special cased" do + assert "Access Point" == Index.device_type_label("access_point") + end + + test "other strings are capitalized" do + assert "Router" == Index.device_type_label("router") + assert "Switch" == Index.device_type_label("switch") + end + + test "non-string falls through to Unknown" do + assert "Unknown" == Index.device_type_label(:access_point) + assert "Unknown" == Index.device_type_label(123) + end + end +end diff --git a/test/towerops_web/live/device_live/show_helpers_test.exs b/test/towerops_web/live/device_live/show_helpers_test.exs new file mode 100644 index 00000000..c8f3c829 --- /dev/null +++ b/test/towerops_web/live/device_live/show_helpers_test.exs @@ -0,0 +1,125 @@ +defmodule ToweropsWeb.DeviceLive.ShowHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.DeviceLive.Show + + describe "utilization_text_color/1" do + test ">= 90 is red" do + assert Show.utilization_text_color(90) =~ "red" + assert Show.utilization_text_color(100) =~ "red" + end + + test ">= 70 < 90 is yellow" do + assert Show.utilization_text_color(70) =~ "yellow" + assert Show.utilization_text_color(89) =~ "yellow" + end + + test "< 70 is green" do + assert Show.utilization_text_color(0) =~ "green" + assert Show.utilization_text_color(69) =~ "green" + end + end + + describe "format_event_type/1" do + test "replaces underscores with spaces and capitalizes" do + assert "Interface down" == Show.format_event_type("interface_down") + assert "Config changed" == Show.format_event_type("config_changed") + end + + test "single word is capitalized" do + assert "Reboot" == Show.format_event_type("reboot") + end + end + + describe "extract_version_number/1" do + test "nil returns nil" do + assert nil == Show.extract_version_number(nil) + end + + test "empty string returns nil" do + assert nil == Show.extract_version_number("") + end + + test "extracts version from text" do + assert "7.12" == Show.extract_version_number("RouterOS 7.12 stable") + assert "1.2.3" == Show.extract_version_number("v1.2.3") + assert "7.12.1" == Show.extract_version_number("Version 7.12.1 rc1") + end + + test "no version in string returns nil" do + assert nil == Show.extract_version_number("no version here") + end + + test "non-string returns nil" do + assert nil == Show.extract_version_number(123) + end + end + + describe "firmware_update_available?/2" do + test "nil device returns false" do + assert false == Show.firmware_update_available?(nil, %{version: "7.13"}) + end + + test "nil available returns false" do + assert false == Show.firmware_update_available?(%{firmware_version: "7.12"}, nil) + end + + test "newer available version returns true" do + snmp = %{firmware_version: "RouterOS 7.12"} + avail = %{version: "7.13"} + assert Show.firmware_update_available?(snmp, avail) + end + + test "same version returns falsy" do + snmp = %{firmware_version: "7.12"} + avail = %{version: "7.12"} + refute Show.firmware_update_available?(snmp, avail) + end + + test "older available returns falsy" do + snmp = %{firmware_version: "7.13"} + avail = %{version: "7.12"} + refute Show.firmware_update_available?(snmp, avail) + end + end + + describe "group_title/1" do + test "known groups" do + assert "SNMP Sensors" == Show.group_title(:snmp_sensors) + assert "SNMP Interfaces" == Show.group_title(:snmp_interfaces) + assert "HTTP Checks" == Show.group_title(:http_checks) + assert "DNS Checks" == Show.group_title(:dns_checks) + assert "SSL Certificate Checks" == Show.group_title(:ssl_checks) + assert "Ping Checks" == Show.group_title(:ping_checks) + assert "Other Checks" == Show.group_title(:other_checks) + end + end + + describe "format_check_value/2" do + test "nil value returns dash" do + assert "-" == Show.format_check_value(%{value: nil}, %{check_type: "any"}) + end + + test "non-map result returns dash" do + assert "-" == Show.format_check_value(%{something: :else}, %{check_type: "any"}) + end + + test "processor value includes %" do + assert "75.0%" == Show.format_check_value(%{value: 75}, %{check_type: "snmp_processor"}) + end + + test "storage value includes %" do + assert "80.1%" == Show.format_check_value(%{value: 80.12}, %{check_type: "snmp_storage"}) + end + + test "ping/http/tcp/dns include ms" do + for t <- ["ping", "http", "tcp", "dns"] do + assert "12.34 ms" == Show.format_check_value(%{value: 12.34}, %{check_type: t}) + end + end + + test "unknown check_type rounds to 2 decimals" do + assert "42.5" == Show.format_check_value(%{value: 42.5}, %{check_type: "weird"}) + end + end +end diff --git a/test/towerops_web/live/device_live/show_smoke_test.exs b/test/towerops_web/live/device_live/show_smoke_test.exs new file mode 100644 index 00000000..9107420b --- /dev/null +++ b/test/towerops_web/live/device_live/show_smoke_test.exs @@ -0,0 +1,55 @@ +defmodule ToweropsWeb.DeviceLive.ShowSmokeTest do + @moduledoc """ + Smoke test for DeviceLive.Show, SiteLive.Show, and DeviceLive.Form mount paths. + Mount is expected to succeed or redirect (e.g. scope mismatch). Both outcomes + still exercise the parameter-resolving code paths. + """ + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Smoke Dev Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Site #{System.unique_integer([:positive])}", + organization_id: org.id + }) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + site_id: site.id, + name: "SmokeDev" + }) + + %{org: org, site: site, device: device} + end + + defp assert_mount_or_redirect(result) do + assert match?({:ok, _, _}, result) or + match?({:error, {:redirect, _}}, result) or + match?({:error, {:live_redirect, _}}, result) + end + + describe "GET /devices/:id" do + test "mount attempt succeeds or redirects", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}")) + end + end + + describe "GET /devices/:id/edit" do + test "mount attempt succeeds or redirects", %{conn: conn, device: device} do + assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/edit")) + end + end + + describe "GET /sites/:id" do + test "mount attempt succeeds or redirects", %{conn: conn, site: site} do + assert_mount_or_redirect(live(conn, ~p"/sites/#{site.id}")) + end + end +end diff --git a/test/towerops_web/live/device_live/tabs_smoke_test.exs b/test/towerops_web/live/device_live/tabs_smoke_test.exs new file mode 100644 index 00000000..939722f8 --- /dev/null +++ b/test/towerops_web/live/device_live/tabs_smoke_test.exs @@ -0,0 +1,74 @@ +defmodule ToweropsWeb.DeviceLive.TabsSmokeTest do + @moduledoc """ + Smoke tests exercising each DeviceLive.Show tab render. Each tab is backed by + a large component module (overview_tab, ports_tab, etc). Hitting each tab path + pulls those components into coverage even for an empty device. + """ + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup do + user = Towerops.AccountsFixtures.user_fixture(enable_totp: true) + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Tabs Org"}, user.id) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: org.id, + name: "Tabs Device", + ip_address: "192.168.77.77" + }) + + %{user: user, org: org, device: device} + end + + defp assert_tab_mounts(conn, user, device, tab) do + conn = log_in_user(conn, user) + url = "/devices/#{device.id}?tab=#{tab}" + + # Dump the LiveView flash/error if mount fails so we can see what's happening. + case live(conn, url) do + {:ok, _view, html} -> + assert is_binary(html) + + {:error, {:redirect, %{to: to}}} -> + IO.puts("redirected to #{to}") + flunk("expected mount, got redirect to #{to}") + + {:error, {:live_redirect, %{to: to}}} -> + IO.puts("live_redirected to #{to}") + flunk("expected mount, got live_redirect to #{to}") + + other -> + flunk("unexpected result: #{inspect(other)}") + end + end + + test "overview tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "overview") + end + + test "ports tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "ports") + end + + test "checks tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "checks") + end + + test "backups tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "backups") + end + + test "wireless tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "wireless") + end + + test "preseem tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "preseem") + end + + test "gaiia tab", %{conn: conn, user: user, device: device} do + assert_tab_mounts(conn, user, device, "gaiia") + end +end diff --git a/test/towerops_web/live/graph_live/show_helpers_test.exs b/test/towerops_web/live/graph_live/show_helpers_test.exs new file mode 100644 index 00000000..a19e33fd --- /dev/null +++ b/test/towerops_web/live/graph_live/show_helpers_test.exs @@ -0,0 +1,93 @@ +defmodule ToweropsWeb.GraphLive.ShowHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.GraphLive.Show + + describe "get_time_range_for_graph/1" do + test "known ranges return {from, to}" do + for range <- ["1h", "6h", "12h", "24h", "7d", "30d"] do + assert {%DateTime{} = from, %DateTime{} = to} = Show.get_time_range_for_graph(range) + assert DateTime.before?(from, to) + end + end + + test "unknown range falls back to 24h" do + {from, to} = Show.get_time_range_for_graph("foo") + diff_hours = DateTime.diff(to, from, :hour) + assert_in_delta diff_hours, 24, 1 + end + + test "1h range is about 1 hour wide" do + {from, to} = Show.get_time_range_for_graph("1h") + assert_in_delta DateTime.diff(to, from, :second), 3600, 2 + end + + test "7d range is about 7 days wide" do + {from, to} = Show.get_time_range_for_graph("7d") + assert_in_delta DateTime.diff(to, from, :day), 7, 1 + end + end + + describe "get_check_unit_and_scale/1" do + test "snmp_sensor uses config[:sensor_unit]" do + assert {"Celsius", true} == + Show.get_check_unit_and_scale(%{check_type: "snmp_sensor", config: %{"sensor_unit" => "Celsius"}}) + end + + test "snmp_sensor with missing unit defaults to empty" do + assert {"", true} == Show.get_check_unit_and_scale(%{check_type: "snmp_sensor", config: %{}}) + end + + test "snmp_processor and snmp_storage are % (no scale)" do + assert {"%", false} == Show.get_check_unit_and_scale(%{check_type: "snmp_processor"}) + assert {"%", false} == Show.get_check_unit_and_scale(%{check_type: "snmp_storage"}) + end + + test "http/tcp/dns are ms with scale" do + assert {"ms", true} == Show.get_check_unit_and_scale(%{check_type: "http"}) + assert {"ms", true} == Show.get_check_unit_and_scale(%{check_type: "tcp"}) + assert {"ms", true} == Show.get_check_unit_and_scale(%{check_type: "dns"}) + end + + test "unknown check_type defaults to empty + scale" do + assert {"", true} == Show.get_check_unit_and_scale(%{check_type: "unknown"}) + assert {"", true} == Show.get_check_unit_and_scale(%{}) + end + end + + describe "format_value/2" do + test "empty unit formats as integer" do + assert "5" == Show.format_value(5.0, "") + assert "7" == Show.format_value(7, "") + end + + test "float with unit" do + assert "23.4 Celsius" == Show.format_value(23.42, "Celsius") + end + + test "integer with unit" do + assert "42.0 ms" == Show.format_value(42, "ms") + end + + test "bps unit with Gbps scale" do + assert "1.5 Gbps" == Show.format_value(1_500_000_000, "bps") + end + + test "bps unit with Mbps scale" do + assert "50.0 Mbps" == Show.format_value(50_000_000, "bps") + end + + test "bps unit with Kbps scale" do + assert "2.5 Kbps" == Show.format_value(2500, "bps") + end + + 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") + end + + test "bps handles negative values (outbound)" do + assert "1.0 Mbps" == Show.format_value(-1_000_000, "bps") + end + end +end diff --git a/test/towerops_web/live/live_helpers_test.exs b/test/towerops_web/live/live_helpers_test.exs new file mode 100644 index 00000000..871e65f5 --- /dev/null +++ b/test/towerops_web/live/live_helpers_test.exs @@ -0,0 +1,369 @@ +defmodule ToweropsWeb.LiveHelpersTest do + @moduledoc """ + Consolidated tests for pure helpers promoted from various LiveView modules. + """ + use ExUnit.Case, async: true + + describe "OnboardingLive.next_step/1" do + test "advances through the onboarding sequence" do + assert :site == ToweropsWeb.OnboardingLive.next_step(:snmp) + assert :billing == ToweropsWeb.OnboardingLive.next_step(:site) + assert :agent == ToweropsWeb.OnboardingLive.next_step(:billing) + assert :done == ToweropsWeb.OnboardingLive.next_step(:agent) + end + end + + describe "OnboardingLive.step_index/1" do + test "returns 0-indexed position" do + assert 0 == ToweropsWeb.OnboardingLive.step_index(:snmp) + assert 1 == ToweropsWeb.OnboardingLive.step_index(:site) + assert 2 == ToweropsWeb.OnboardingLive.step_index(:billing) + assert 3 == ToweropsWeb.OnboardingLive.step_index(:agent) + end + end + + describe "InsightsLive.Index.urgency_classes/1" do + alias ToweropsWeb.InsightsLive.Index + + test "critical is red" do + assert String.contains?(Index.urgency_classes("critical"), "red") + end + + test "warning is yellow" do + assert String.contains?(Index.urgency_classes("warning"), "yellow") + end + + test "info is blue" do + assert String.contains?(Index.urgency_classes("info"), "blue") + end + + test "unknown is gray" do + assert String.contains?(Index.urgency_classes("mystery"), "gray") + assert String.contains?(Index.urgency_classes(nil), "gray") + end + end + + describe "InsightsLive.Index.source_classes/1" do + alias ToweropsWeb.InsightsLive.Index + + test "preseem is indigo" do + assert String.contains?(Index.source_classes("preseem"), "indigo") + end + + test "gaiia is emerald" do + assert String.contains?(Index.source_classes("gaiia"), "emerald") + end + + test "snmp is orange" do + assert String.contains?(Index.source_classes("snmp"), "orange") + end + + test "system is gray" do + assert String.contains?(Index.source_classes("system"), "gray") + end + + test "unknown is gray" do + assert String.contains?(Index.source_classes("x"), "gray") + end + end + + describe "InsightsLive.Index.build_filter_params/2" do + alias ToweropsWeb.InsightsLive.Index + + test "merges overrides into base" do + base = %{"status" => "active"} + result = Index.build_filter_params(base, %{"urgency" => "critical"}) + + assert result == %{"status" => "active", "urgency" => "critical"} + end + + test "drops nil values" do + base = %{"status" => "active"} + result = Index.build_filter_params(base, %{"urgency" => nil}) + + refute Map.has_key?(result, "urgency") + end + + test "override wins over base" do + base = %{"status" => "active"} + result = Index.build_filter_params(base, %{"status" => "resolved"}) + + assert result == %{"status" => "resolved"} + end + end + + describe "InsightsLive.Index selection helpers" do + alias ToweropsWeb.InsightsLive.Index + + test "selected?/2 returns true when id is in set" do + ids = MapSet.new(["a", "b"]) + assert Index.selected?(ids, "a") + refute Index.selected?(ids, "c") + end + + test "any_selected?/1 returns true for non-empty set" do + assert Index.any_selected?(MapSet.new(["a"])) + refute Index.any_selected?(MapSet.new()) + end + end + + describe "ActivityFeedLive.severity_dot_color/2" do + alias ToweropsWeb.ActivityFeedLive + + test "critical is red regardless of type" do + assert "bg-red-500" == ActivityFeedLive.severity_dot_color(:critical, :anything) + end + + test "warning is yellow" do + assert "bg-yellow-500" == ActivityFeedLive.severity_dot_color(:warning, :anything) + end + + test "info + alert_resolved is green" do + assert "bg-green-500" == ActivityFeedLive.severity_dot_color(:info, :alert_resolved) + end + + test "info + sync is blue" do + assert "bg-blue-500" == ActivityFeedLive.severity_dot_color(:info, :sync) + end + + test "info + device_added is cyan" do + assert "bg-cyan-500" == ActivityFeedLive.severity_dot_color(:info, :device_added) + end + + test "info + unknown defaults to gray" do + assert "bg-gray-400" == ActivityFeedLive.severity_dot_color(:info, :other) + end + + test "catch-all is gray" do + assert "bg-gray-400" == ActivityFeedLive.severity_dot_color(:unknown, :other) + end + end + + describe "ActivityFeedLive.severity_text_color/2" do + alias ToweropsWeb.ActivityFeedLive + + test "critical is red text" do + result = ActivityFeedLive.severity_text_color(:critical, :alert_fired) + assert String.contains?(result, "red") + end + + test "alert_resolved is green text" do + result = ActivityFeedLive.severity_text_color(:info, :alert_resolved) + assert String.contains?(result, "green") + end + + test "fallback is default gray/white" do + result = ActivityFeedLive.severity_text_color(:info, :sync) + assert String.contains?(result, "gray") or String.contains?(result, "white") + end + end + + describe "ActivityFeedLive.type_badge_class/1" do + alias ToweropsWeb.ActivityFeedLive + + test "known types map to their colors" do + for {type, color} <- [ + {:config_change, "orange"}, + {:alert_fired, "red"}, + {:alert_resolved, "green"}, + {:device_event, "purple"}, + {:sync, "blue"}, + {:device_added, "cyan"} + ] do + assert String.contains?(ActivityFeedLive.type_badge_class(type), color), + "expected #{type} badge to contain '#{color}'" + end + end + + test "unknown defaults to gray" do + assert String.contains?(ActivityFeedLive.type_badge_class(:other), "gray") + end + end + + describe "ActivityFeedLive.type_label/1" do + alias ToweropsWeb.ActivityFeedLive + + test "returns human labels" do + assert "Config" == ActivityFeedLive.type_label(:config_change) + assert "Alert" == ActivityFeedLive.type_label(:alert_fired) + assert "Resolved" == ActivityFeedLive.type_label(:alert_resolved) + assert "Event" == ActivityFeedLive.type_label(:device_event) + assert "Sync" == ActivityFeedLive.type_label(:sync) + assert "Device" == ActivityFeedLive.type_label(:device_added) + assert "Other" == ActivityFeedLive.type_label(:unknown) + end + end + + describe "ActivityFeedLive.relative_time/2" do + alias ToweropsWeb.ActivityFeedLive + + setup do + now = ~U[2026-04-23 20:00:00Z] + %{now: now} + end + + test "very recent = 'just now'", %{now: now} do + ts = DateTime.add(now, -2, :second) + assert "just now" == ActivityFeedLive.relative_time(ts, now) + end + + test "seconds ago", %{now: now} do + ts = DateTime.add(now, -30, :second) + assert "30s ago" == ActivityFeedLive.relative_time(ts, now) + end + + test "minutes ago", %{now: now} do + ts = DateTime.add(now, -600, :second) + assert "10m ago" == ActivityFeedLive.relative_time(ts, now) + end + + test "hours ago", %{now: now} do + ts = DateTime.add(now, -3 * 3600, :second) + assert "3h ago" == ActivityFeedLive.relative_time(ts, now) + end + + test "yesterday", %{now: now} do + ts = DateTime.add(now, -30 * 3600, :second) + assert "yesterday" == ActivityFeedLive.relative_time(ts, now) + end + + test "days ago", %{now: now} do + ts = DateTime.add(now, -5 * 86_400, :second) + assert "5d ago" == ActivityFeedLive.relative_time(ts, now) + end + + test "over a week shows absolute date", %{now: now} do + ts = DateTime.add(now, -30 * 86_400, :second) + result = ActivityFeedLive.relative_time(ts, now) + # Absolute format like "Mar 24, 2026" + assert result =~ ~r/\w+ \d+, \d{4}/ + end + end + + describe "MaintenanceLive.Form.maybe_clear_scope/2" do + alias ToweropsWeb.MaintenanceLive.Form + + test "org scope nils both site_id and device_id" do + result = Form.maybe_clear_scope(%{"site_id" => "s1", "device_id" => "d1"}, "org") + assert result["site_id"] == nil + assert result["device_id"] == nil + end + + test "site scope nils only device_id" do + result = Form.maybe_clear_scope(%{"site_id" => "s1", "device_id" => "d1"}, "site") + assert result["site_id"] == "s1" + assert result["device_id"] == nil + end + + test "device scope preserves both" do + result = Form.maybe_clear_scope(%{"site_id" => "s1", "device_id" => "d1"}, "device") + assert result["site_id"] == "s1" + assert result["device_id"] == "d1" + end + end + + describe "MaintenanceLive.Form.format_datetime_local/1" do + alias ToweropsWeb.MaintenanceLive.Form + + test "nil returns empty string" do + assert "" == Form.format_datetime_local(nil) + end + + test "non-DateTime returns empty string" do + assert "" == Form.format_datetime_local("not a datetime") + assert "" == Form.format_datetime_local(42) + end + + test "DateTime formatted as HTML datetime-local" do + dt = ~U[2026-04-23 14:30:00Z] + assert "2026-04-23T14:30" == Form.format_datetime_local(dt) + end + end + + describe "ReportsLive helpers" do + alias ToweropsWeb.ReportsLive + + test "humanize_type/1 known types" do + assert "Uptime Summary" == ReportsLive.humanize_type("uptime_summary") + assert "Alert History" == ReportsLive.humanize_type("alert_history") + assert "Capacity Trends" == ReportsLive.humanize_type("capacity_trends") + assert "RF Link Health" == ReportsLive.humanize_type("rf_link_health") + end + + test "humanize_type/1 passes through unknown" do + assert "custom" == ReportsLive.humanize_type("custom") + end + + test "humanize_schedule/1 capitalizes type" do + assert "Weekly" == ReportsLive.humanize_schedule(%{"type" => "weekly"}) + assert "Daily" == ReportsLive.humanize_schedule(%{"type" => "daily"}) + end + + test "humanize_schedule/1 returns dash for missing type" do + assert "-" == ReportsLive.humanize_schedule(%{}) + assert "-" == ReportsLive.humanize_schedule(nil) + end + + test "format_recipients/1 joins list with commas" do + assert "a@b.com, c@d.com" == ReportsLive.format_recipients(["a@b.com", "c@d.com"]) + end + + test "format_recipients/1 returns dash for non-list" do + assert "-" == ReportsLive.format_recipients(nil) + assert "-" == ReportsLive.format_recipients("string") + end + + test "status_badge/1 known statuses" do + assert {"Success", class} = ReportsLive.status_badge("success") + assert String.contains?(class, "green") + + assert {"Failed", class} = ReportsLive.status_badge("failed") + assert String.contains?(class, "red") + + assert {"Partial", class} = ReportsLive.status_badge("partial_failure") + assert String.contains?(class, "yellow") + end + + test "status_badge/1 unknown maps to 'Never Run'" do + assert {"Never Run", class} = ReportsLive.status_badge(nil) + assert String.contains?(class, "gray") + end + end + + describe "UserRegistrationLive.normalize_consent_params/1" do + alias ToweropsWeb.UserRegistrationLive + + test "'on' becomes true for both fields" do + result = + UserRegistrationLive.normalize_consent_params(%{ + "privacy_policy_consent" => "on", + "terms_of_service_consent" => "on" + }) + + assert result["privacy_policy_consent"] == true + assert result["terms_of_service_consent"] == true + end + + test "preserves existing boolean values" do + result = + UserRegistrationLive.normalize_consent_params(%{ + "privacy_policy_consent" => true, + "terms_of_service_consent" => false + }) + + assert result["privacy_policy_consent"] == true + assert result["terms_of_service_consent"] == false + end + + test "missing fields remain nil" do + result = UserRegistrationLive.normalize_consent_params(%{}) + assert result["privacy_policy_consent"] == nil + assert result["terms_of_service_consent"] == nil + end + + test "other values pass through unchanged" do + result = UserRegistrationLive.normalize_consent_params(%{"privacy_policy_consent" => "nope"}) + assert result["privacy_policy_consent"] == "nope" + end + end +end diff --git a/test/towerops_web/live/mikrotik_backup_live/compare_helpers_test.exs b/test/towerops_web/live/mikrotik_backup_live/compare_helpers_test.exs new file mode 100644 index 00000000..9c47d000 --- /dev/null +++ b/test/towerops_web/live/mikrotik_backup_live/compare_helpers_test.exs @@ -0,0 +1,33 @@ +defmodule ToweropsWeb.MikrotikBackupLive.CompareHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.MikrotikBackupLive.Compare + + describe "format_bytes/1" do + test "formats GB" do + assert "1.0 GB" == Compare.format_bytes(1_073_741_824) + assert "2.5 GB" == Compare.format_bytes(2_684_354_560) + end + + test "formats MB" do + assert "1.0 MB" == Compare.format_bytes(1_048_576) + assert "10.5 MB" == Compare.format_bytes(11_010_048) + end + + test "formats KB" do + assert "1.0 KB" == Compare.format_bytes(1_024) + assert "2.0 KB" == Compare.format_bytes(2_048) + end + + test "formats B" do + assert "0 B" == Compare.format_bytes(0) + assert "512 B" == Compare.format_bytes(512) + end + + test "handles non-integer input" do + assert "Invalid byte count" == Compare.format_bytes(nil) + assert "Invalid byte count" == Compare.format_bytes(-5) + assert "Invalid byte count" == Compare.format_bytes("foo") + end + end +end diff --git a/test/towerops_web/live/org/gaiia_mapping_live_helpers_test.exs b/test/towerops_web/live/org/gaiia_mapping_live_helpers_test.exs new file mode 100644 index 00000000..a9387a08 --- /dev/null +++ b/test/towerops_web/live/org/gaiia_mapping_live_helpers_test.exs @@ -0,0 +1,115 @@ +defmodule ToweropsWeb.Org.GaiiaMappingLiveHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.Org.GaiiaMappingLive + + describe "apply_device_filter/2" do + setup do + rows = [ + %{device: %{id: 1}, inventory_item: %{id: "a"}}, + %{device: %{id: 2}, inventory_item: nil}, + %{device: %{id: 3}, inventory_item: %{id: "c"}} + ] + + %{rows: rows} + end + + test "filter=linked keeps only rows with inventory_item", %{rows: rows} do + result = GaiiaMappingLive.apply_device_filter(rows, "linked") + assert length(result) == 2 + assert Enum.all?(result, & &1.inventory_item) + end + + test "filter=unlinked keeps only rows without inventory_item", %{rows: rows} do + result = GaiiaMappingLive.apply_device_filter(rows, "unlinked") + assert [%{device: %{id: 2}}] = result + end + + test "anything else returns all rows", %{rows: rows} do + assert rows == GaiiaMappingLive.apply_device_filter(rows, "all") + assert rows == GaiiaMappingLive.apply_device_filter(rows, "foo") + end + end + + describe "apply_site_filter/2" do + setup do + rows = [ + %{site: %{id: 1}, network_site: %{id: "a"}}, + %{site: %{id: 2}, network_site: nil} + ] + + %{rows: rows} + end + + test "filter=linked", %{rows: rows} do + assert [%{site: %{id: 1}}] = GaiiaMappingLive.apply_site_filter(rows, "linked") + end + + test "filter=unlinked", %{rows: rows} do + assert [%{site: %{id: 2}}] = GaiiaMappingLive.apply_site_filter(rows, "unlinked") + end + + test "all returns everything", %{rows: rows} do + assert rows == GaiiaMappingLive.apply_site_filter(rows, "all") + end + end + + describe "add_device_suggestion/3" do + test "returns acc unchanged when device already linked" do + row = %{device: %{id: "d1", ip_address: "1.1.1.1"}, inventory_item: %{id: "x"}} + assert %{} == GaiiaMappingLive.add_device_suggestion(%{}, row, %{"1.1.1.1" => [%{id: "y"}]}) + end + + test "adds suggestion when IP matches an unmapped item" do + row = %{device: %{id: "d1", ip_address: "1.1.1.1"}, inventory_item: nil} + unmapped = %{"1.1.1.1" => [%{id: "inv1", ip_address: "1.1.1.1"}]} + + result = GaiiaMappingLive.add_device_suggestion(%{}, row, unmapped) + + assert %{ + "d1" => [%{entity: %{id: "inv1"}, match_type: "ip", confidence: :high}] + } = result + end + + test "no suggestion when no IP match" do + row = %{device: %{id: "d1", ip_address: "9.9.9.9"}, inventory_item: nil} + assert %{} == GaiiaMappingLive.add_device_suggestion(%{}, row, %{}) + end + end + + describe "add_site_suggestion/3" do + test "returns acc when site already linked" do + row = %{site: %{id: 1, name: "Foo"}, network_site: %{id: "ns"}} + assert %{} == GaiiaMappingLive.add_site_suggestion(%{}, row, [%{name: "foo"}]) + end + + test "returns acc when site has nil/empty name" do + row1 = %{site: %{id: 1, name: nil}, network_site: nil} + row2 = %{site: %{id: 2, name: ""}, network_site: nil} + assert %{} == GaiiaMappingLive.add_site_suggestion(%{}, row1, [%{name: "anything"}]) + assert %{} == GaiiaMappingLive.add_site_suggestion(%{}, row2, [%{name: "anything"}]) + end + + test "adds suggestion on case-insensitive substring match" do + row = %{site: %{id: "s1", name: "Denver POP"}, network_site: nil} + unmapped = [%{id: "ns1", name: "denver pop site"}] + + result = GaiiaMappingLive.add_site_suggestion(%{}, row, unmapped) + assert %{"s1" => [%{entity: %{id: "ns1"}, match_type: "name", confidence: :medium}]} = result + end + + test "matches when gaiia name is subset of site name" do + row = %{site: %{id: "s2", name: "Main Tower DEN"}, network_site: nil} + unmapped = [%{id: "ns2", name: "DEN"}] + + result = GaiiaMappingLive.add_site_suggestion(%{}, row, unmapped) + assert Map.has_key?(result, "s2") + end + + test "no match when names disjoint" do + row = %{site: %{id: "s3", name: "Alpha"}, network_site: nil} + unmapped = [%{id: "ns3", name: "Bravo"}] + assert %{} == GaiiaMappingLive.add_site_suggestion(%{}, row, unmapped) + end + end +end diff --git a/test/towerops_web/live/org/settings_live_helpers_test.exs b/test/towerops_web/live/org/settings_live_helpers_test.exs new file mode 100644 index 00000000..76a3bb3e --- /dev/null +++ b/test/towerops_web/live/org/settings_live_helpers_test.exs @@ -0,0 +1,382 @@ +defmodule ToweropsWeb.Org.SettingsLiveHelpersTest do + @moduledoc """ + Unit tests for pure helpers in Org.SettingsLive. The full LiveView round-trip + requires org membership fixtures and is covered by integration tests elsewhere. + """ + use ExUnit.Case, async: true + + alias Towerops.Integrations.Integration + alias ToweropsWeb.Org.SettingsLive + + describe "validate_present/2" do + test "returns error for nil" do + assert {:error, "missing"} == SettingsLive.validate_present(nil, "missing") + end + + test "returns error for empty string" do + assert {:error, "missing"} == SettingsLive.validate_present("", "missing") + end + + test "returns :ok for non-empty string" do + assert :ok == SettingsLive.validate_present("valid", "missing") + end + + test "returns :ok for any non-nil non-empty value" do + assert :ok == SettingsLive.validate_present(" ", "missing") + assert :ok == SettingsLive.validate_present("a", "missing") + end + end + + describe "non_empty/2" do + test "returns fallback for empty string" do + assert "fallback" == SettingsLive.non_empty("", "fallback") + end + + test "returns fallback for nil" do + assert "fallback" == SettingsLive.non_empty(nil, "fallback") + end + + test "returns value when non-empty" do + assert "actual" == SettingsLive.non_empty("actual", "fallback") + assert " " == SettingsLive.non_empty(" ", "fallback") + end + end + + describe "format_connection_result/1" do + test "ok body gives success message" do + assert {:ok, "Connection successful"} == SettingsLive.format_connection_result({:ok, %{}}) + end + + test "unauthorized gives invalid API key" do + assert {:error, "Invalid API key"} == SettingsLive.format_connection_result({:error, :unauthorized}) + end + + test "forbidden gives access forbidden" do + assert {:error, "Access forbidden"} == SettingsLive.format_connection_result({:error, :forbidden}) + end + + test "unexpected status code is formatted" do + assert {:error, "API returned unexpected status: 500"} == + SettingsLive.format_connection_result({:error, {:unexpected_status, 500}}) + end + + test "timeout gives timeout message" do + assert {:error, "Connection timeout - unable to reach API"} == + SettingsLive.format_connection_result({:error, %{reason: :timeout}}) + end + + test "econnrefused gives connection refused" do + assert {:error, "Connection refused - check API endpoint"} == + SettingsLive.format_connection_result({:error, %{reason: :econnrefused}}) + end + + test "nxdomain gives DNS failure" do + assert {:error, "DNS lookup failed - check API endpoint"} == + SettingsLive.format_connection_result({:error, %{reason: :nxdomain}}) + end + + test "graphql errors list formatted" do + assert {:error, msg} = + SettingsLive.format_connection_result({:error, {:graphql_errors, [%{"message" => "bad"}]}}) + + assert String.contains?(msg, "bad") + end + + test "generic error falls through to default message" do + assert {:error, "Connection failed - please check your API credentials and try again"} == + SettingsLive.format_connection_result({:error, :something_weird}) + end + end + + describe "get_credential/2" do + test "nil integration returns empty string" do + assert "" == SettingsLive.get_credential(nil, "api_key") + end + + test "integration with matching credential returns value" do + integration = %Integration{ + credentials: %{"api_key" => "secret-123"} + } + + assert "secret-123" == SettingsLive.get_credential(integration, "api_key") + end + + test "integration with missing credential returns empty string" do + integration = %Integration{credentials: %{"other" => "x"}} + assert "" == SettingsLive.get_credential(integration, "api_key") + end + + test "integration without credentials map returns empty string" do + integration = %Integration{credentials: nil} + assert "" == SettingsLive.get_credential(integration, "api_key") + end + end + + describe "normalize_standard_params/2" do + test "builds credentials with api_key and webhook_secret" do + result = + SettingsLive.normalize_standard_params(%{"api_key" => "k", "webhook_secret" => "s"}, nil) + + assert %{credentials: %{"api_key" => "k", "webhook_secret" => "s"}} = result + end + + test "preserves existing webhook_secret when blank in params" do + existing = %Integration{credentials: %{"webhook_secret" => "saved"}} + result = SettingsLive.normalize_standard_params(%{"api_key" => "k"}, existing) + assert result.credentials["webhook_secret"] == "saved" + end + + test "empty string existing webhook_secret uses empty" do + result = SettingsLive.normalize_standard_params(%{"api_key" => "k"}, nil) + assert result.credentials["webhook_secret"] == "" + end + + test "preserves sync_interval when present" do + result = + SettingsLive.normalize_standard_params( + %{"api_key" => "k", "sync_interval_minutes" => "60"}, + nil + ) + + assert result[:sync_interval_minutes] == "60" + end + + test "omits sync_interval when empty" do + result = + SettingsLive.normalize_standard_params( + %{"api_key" => "k", "sync_interval_minutes" => ""}, + nil + ) + + refute Map.has_key?(result, :sync_interval_minutes) + end + end + + describe "normalize_netbox_params/2" do + test "builds netbox credentials with URL trimming and boolean conversion" do + result = + SettingsLive.normalize_netbox_params( + %{ + "url" => " https://netbox/ ", + "api_token" => "t", + "sync_direction" => "pull", + "sync_devices" => "true", + "sync_sites" => "false", + "sync_ip_addresses" => "true", + "sync_interfaces" => "false", + "device_role_filter" => " core ", + "site_filter" => "", + "tag_filter" => "prod" + }, + nil + ) + + creds = result.credentials + assert creds["url"] == "https://netbox/" + assert creds["api_token"] == "t" + assert creds["sync_direction"] == "pull" + assert creds["sync_devices"] == true + assert creds["sync_sites"] == false + assert creds["sync_ip_addresses"] == true + assert creds["sync_interfaces"] == false + assert creds["device_role_filter"] == "core" + assert creds["site_filter"] == "" + assert creds["tag_filter"] == "prod" + end + + test "defaults all booleans correctly when not provided" do + result = SettingsLive.normalize_netbox_params(%{"url" => "u", "api_token" => "t"}, nil) + creds = result.credentials + assert creds["sync_devices"] == true + assert creds["sync_sites"] == true + assert creds["sync_ip_addresses"] == false + assert creds["sync_interfaces"] == false + assert creds["sync_direction"] == "pull" + end + end + + describe "normalize_sonar_params/1" do + test "builds sonar credentials" do + result = SettingsLive.normalize_sonar_params(%{"instance_url" => " https://s/ ", "api_token" => "t"}) + assert result.credentials == %{"instance_url" => "https://s/", "api_token" => "t"} + end + + test "includes sync_interval when present" do + result = + SettingsLive.normalize_sonar_params(%{ + "instance_url" => "u", + "api_token" => "t", + "sync_interval_minutes" => "30" + }) + + assert result[:sync_interval_minutes] == "30" + end + end + + describe "normalize_splynx_params/1" do + test "builds splynx credentials" do + result = + SettingsLive.normalize_splynx_params(%{ + "instance_url" => " https://s/ ", + "api_key" => "k", + "api_secret" => "sec" + }) + + assert result.credentials == %{ + "instance_url" => "https://s/", + "api_key" => "k", + "api_secret" => "sec" + } + end + end + + describe "normalize_params/2" do + test "dispatches to netbox when provider is netbox" do + result = SettingsLive.normalize_params(%{"provider" => "netbox", "url" => "u"}, nil) + assert result.credentials["url"] == "u" + assert result.credentials["sync_direction"] == "pull" + end + + test "dispatches to sonar" do + result = + SettingsLive.normalize_params( + %{"provider" => "sonar", "instance_url" => "u", "api_token" => "t"}, + nil + ) + + assert result.credentials == %{"instance_url" => "u", "api_token" => "t"} + end + + test "dispatches to splynx" do + result = + SettingsLive.normalize_params( + %{ + "provider" => "splynx", + "instance_url" => "u", + "api_key" => "k", + "api_secret" => "s" + }, + nil + ) + + assert result.credentials == %{"instance_url" => "u", "api_key" => "k", "api_secret" => "s"} + end + + test "falls back to standard for unknown provider" do + result = SettingsLive.normalize_params(%{"api_key" => "k"}, nil) + assert result.credentials == %{"api_key" => "k", "webhook_secret" => ""} + end + + test "uses existing integration's provider when set" do + existing = %Integration{provider: "sonar"} + + result = + SettingsLive.normalize_params(%{"instance_url" => "u", "api_token" => "t"}, existing) + + assert result.credentials == %{"instance_url" => "u", "api_token" => "t"} + end + end + + describe "default_netbox_config/0" do + test "returns all expected keys with default values" do + config = SettingsLive.default_netbox_config() + assert config["url"] == "" + assert config["api_token"] == "" + assert config["sync_direction"] == "pull" + assert config["sync_devices"] == true + assert config["sync_sites"] == true + assert config["sync_ip_addresses"] == false + assert config["sync_interfaces"] == false + end + end + + describe "load_netbox_config/1" do + test "nil returns defaults" do + assert SettingsLive.default_netbox_config() == SettingsLive.load_netbox_config(nil) + end + + test "unknown shape returns defaults" do + assert SettingsLive.default_netbox_config() == SettingsLive.load_netbox_config(%{foo: :bar}) + end + + test "integration returns its credentials merged with defaults" do + integration = %Integration{ + credentials: %{ + "url" => "https://my-netbox/", + "api_token" => "secret", + "sync_devices" => false, + "device_role_filter" => "tower" + } + } + + config = SettingsLive.load_netbox_config(integration) + assert config["url"] == "https://my-netbox/" + assert config["api_token"] == "secret" + assert config["sync_devices"] == false + assert config["device_role_filter"] == "tower" + # Not overridden, stays default + assert config["sync_sites"] == true + end + end + + describe "load_integration_credentials/2" do + test "sonar extracts instance_url and api_token" do + integration = %Integration{ + credentials: %{"instance_url" => "s", "api_token" => "t"} + } + + assert %{"instance_url" => "s", "api_token" => "t"} == + SettingsLive.load_integration_credentials(integration, "sonar") + end + + test "splynx extracts instance_url, api_key, and api_secret" do + integration = %Integration{ + credentials: %{"instance_url" => "s", "api_key" => "k", "api_secret" => "sec"} + } + + assert %{"instance_url" => "s", "api_key" => "k", "api_secret" => "sec"} == + SettingsLive.load_integration_credentials(integration, "splynx") + end + + test "default provider extracts only api_key" do + integration = %Integration{ + credentials: %{"api_key" => "k", "instance_url" => "ignored"} + } + + assert %{"api_key" => "k"} == SettingsLive.load_integration_credentials(integration, "pagerduty") + end + + test "nil integration returns blanks" do + assert %{"instance_url" => "", "api_token" => ""} == + SettingsLive.load_integration_credentials(nil, "sonar") + end + end + + describe "extract_credentials_from_params/2" do + test "sonar extracts instance_url and api_token" do + assert %{"instance_url" => "u", "api_token" => "t"} == + SettingsLive.extract_credentials_from_params( + %{"instance_url" => "u", "api_token" => "t"}, + "sonar" + ) + end + + test "splynx extracts instance_url, api_key, api_secret" do + assert %{"instance_url" => "u", "api_key" => "k", "api_secret" => "sec"} == + SettingsLive.extract_credentials_from_params( + %{"instance_url" => "u", "api_key" => "k", "api_secret" => "sec"}, + "splynx" + ) + end + + test "default provider just takes api_key" do + assert %{"api_key" => "k"} == + SettingsLive.extract_credentials_from_params(%{"api_key" => "k"}, "pagerduty") + end + + test "missing fields become empty strings" do + assert %{"instance_url" => "", "api_token" => ""} == + SettingsLive.extract_credentials_from_params(%{}, "sonar") + end + end +end diff --git a/test/towerops_web/live/rf_link_health_live_helpers_test.exs b/test/towerops_web/live/rf_link_health_live_helpers_test.exs new file mode 100644 index 00000000..891825b3 --- /dev/null +++ b/test/towerops_web/live/rf_link_health_live_helpers_test.exs @@ -0,0 +1,237 @@ +defmodule ToweropsWeb.RfLinkHealthLiveHelpersTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ToweropsWeb.RfLinkHealthLive + + describe "health_color/1" do + test "known health states" do + assert "text-green-600 dark:text-green-400" == RfLinkHealthLive.health_color(:healthy) + assert "text-yellow-600 dark:text-yellow-400" == RfLinkHealthLive.health_color(:degraded) + assert "text-red-600 dark:text-red-400" == RfLinkHealthLive.health_color(:critical) + end + + test "unknown maps to gray" do + assert String.contains?(RfLinkHealthLive.health_color(:other), "gray") + assert String.contains?(RfLinkHealthLive.health_color(nil), "gray") + end + end + + describe "health_bg/1" do + test "each state maps to its color class" do + assert String.contains?(RfLinkHealthLive.health_bg(:healthy), "green") + assert String.contains?(RfLinkHealthLive.health_bg(:degraded), "yellow") + assert String.contains?(RfLinkHealthLive.health_bg(:critical), "red") + assert String.contains?(RfLinkHealthLive.health_bg(:other), "gray") + end + end + + describe "health_label/1" do + test "returns human-readable labels" do + assert "Healthy" == RfLinkHealthLive.health_label(:healthy) + assert "Degraded" == RfLinkHealthLive.health_label(:degraded) + assert "Critical" == RfLinkHealthLive.health_label(:critical) + assert "Unknown" == RfLinkHealthLive.health_label(:other) + end + end + + describe "signal_bar_width/1" do + test "nil returns 0" do + assert 0 == RfLinkHealthLive.signal_bar_width(nil) + end + + test "maps -100 dBm to 0%" do + assert 0 == RfLinkHealthLive.signal_bar_width(-100) + end + + test "maps -30 dBm to 100%" do + assert 100 == RfLinkHealthLive.signal_bar_width(-30) + end + + test "clamps to -100..-30" do + assert 0 == RfLinkHealthLive.signal_bar_width(-120) + assert 100 == RfLinkHealthLive.signal_bar_width(-10) + end + + test "mid-range signal scales proportionally" do + # -65 dBm → (35/70)*100 = 50 + assert 50 == RfLinkHealthLive.signal_bar_width(-65) + end + + property "output is always 0..100" do + check all(signal <- integer(-200..0)) do + width = RfLinkHealthLive.signal_bar_width(signal) + assert width >= 0 and width <= 100 + end + end + end + + describe "signal_bar_color/1" do + test "strong signal green" do + assert "bg-green-500" == RfLinkHealthLive.signal_bar_color(-50) + end + + test "mid-range yellow" do + assert "bg-yellow-500" == RfLinkHealthLive.signal_bar_color(-70) + end + + test "weak red" do + assert "bg-red-500" == RfLinkHealthLive.signal_bar_color(-90) + end + + test "nil gray" do + assert String.contains?(RfLinkHealthLive.signal_bar_color(nil), "gray") + end + end + + describe "snr_bar_width/1" do + test "nil returns 0" do + assert 0 == RfLinkHealthLive.snr_bar_width(nil) + end + + test "clamps to 0..40 dB range" do + assert 0 == RfLinkHealthLive.snr_bar_width(-10) + assert 100 == RfLinkHealthLive.snr_bar_width(50) + assert 100 == RfLinkHealthLive.snr_bar_width(40) + assert 0 == RfLinkHealthLive.snr_bar_width(0) + end + + test "midpoint is 50%" do + assert 50 == RfLinkHealthLive.snr_bar_width(20) + end + end + + describe "snr_bar_color/1" do + test "high SNR green" do + assert "bg-green-500" == RfLinkHealthLive.snr_bar_color(30) + end + + test "mid SNR yellow" do + assert "bg-yellow-500" == RfLinkHealthLive.snr_bar_color(18) + end + + test "low SNR red" do + assert "bg-red-500" == RfLinkHealthLive.snr_bar_color(10) + end + + test "nil gray" do + assert String.contains?(RfLinkHealthLive.snr_bar_color(nil), "gray") + end + end + + describe "format_signal/1 + format_snr/1" do + test "nil becomes dash" do + assert "-" == RfLinkHealthLive.format_signal(nil) + assert "-" == RfLinkHealthLive.format_snr(nil) + end + + test "formatted with units" do + assert "-55 dBm" == RfLinkHealthLive.format_signal(-55) + assert "30 dB" == RfLinkHealthLive.format_snr(30) + end + end + + describe "format_rate/1" do + test "nil becomes dash" do + assert "-" == RfLinkHealthLive.format_rate(nil) + end + + test "under 1000 Mbps stays as Mbps" do + assert "500 Mbps" == RfLinkHealthLive.format_rate(500) + end + + test "over 1000 Mbps becomes Gbps" do + assert "1.5 Gbps" == RfLinkHealthLive.format_rate(1500) + assert "2.0 Gbps" == RfLinkHealthLive.format_rate(2000) + end + end + + describe "format_distance/1" do + test "nil becomes dash" do + assert "-" == RfLinkHealthLive.format_distance(nil) + end + + test "< 1km stays in meters" do + assert "500 m" == RfLinkHealthLive.format_distance(500) + end + + test ">= 1km becomes km" do + assert "1.5 km" == RfLinkHealthLive.format_distance(1500) + end + end + + describe "format_uptime/1" do + test "nil becomes dash" do + assert "-" == RfLinkHealthLive.format_uptime(nil) + end + + test "over 1 day: days+hours" do + assert "2d 3h" == RfLinkHealthLive.format_uptime(2 * 86_400 + 3 * 3600) + end + + test "over 1 hour: hours only" do + assert "5h" == RfLinkHealthLive.format_uptime(5 * 3600 + 30 * 60) + end + + test "less than an hour: < 1h" do + assert "< 1h" == RfLinkHealthLive.format_uptime(30 * 60) + end + end + + describe "device_name/1 + link_name/1" do + test "device_name prefers name, then ip_address, then dash" do + assert "router" == RfLinkHealthLive.device_name(%{device: %{name: "router", ip_address: "1.1.1.1"}}) + assert "1.1.1.1" == RfLinkHealthLive.device_name(%{device: %{name: nil, ip_address: "1.1.1.1"}}) + assert "-" == RfLinkHealthLive.device_name(%{}) + assert "-" == RfLinkHealthLive.device_name(nil) + end + + test "link_name prefers hostname, then ip, then mac, then dash" do + assert "host" == RfLinkHealthLive.link_name(%{hostname: "host", ip_address: "1.2.3.4", mac_address: "aa:bb"}) + assert "1.2.3.4" == RfLinkHealthLive.link_name(%{hostname: nil, ip_address: "1.2.3.4", mac_address: "aa:bb"}) + assert "aa:bb" == RfLinkHealthLive.link_name(%{hostname: nil, ip_address: nil, mac_address: "aa:bb"}) + assert "-" == RfLinkHealthLive.link_name(%{hostname: nil, ip_address: nil, mac_address: nil}) + end + end + + describe "stat_card_class/2" do + test ":all filter always gets ring when active" do + assert "ring-2 ring-blue-500" == RfLinkHealthLive.stat_card_class(:all, :all) + end + + test "matching filter gets ring" do + assert "ring-2 ring-blue-500" == RfLinkHealthLive.stat_card_class(:healthy, :healthy) + end + + test "non-matching filter is empty" do + assert "" == RfLinkHealthLive.stat_card_class(:healthy, :degraded) + end + end + + describe "build_sparkline_points/1" do + test "nil for fewer than 2 readings" do + assert nil == RfLinkHealthLive.build_sparkline_points([]) + assert nil == RfLinkHealthLive.build_sparkline_points([%{signal_strength: -50}]) + end + + test "nil when all signals are nil" do + readings = [%{signal_strength: nil}, %{signal_strength: nil}, %{signal_strength: nil}] + assert nil == RfLinkHealthLive.build_sparkline_points(readings) + end + + test "returns points map for valid readings" do + readings = [ + %{signal_strength: -50}, + %{signal_strength: -55}, + %{signal_strength: -60} + ] + + result = RfLinkHealthLive.build_sparkline_points(readings) + assert result.width == 200 + assert result.height == 40 + assert is_binary(result.points) + # 3 points separated by spaces + assert length(String.split(result.points, " ")) == 3 + end + end +end diff --git a/test/towerops_web/live/site_live/index_helpers_test.exs b/test/towerops_web/live/site_live/index_helpers_test.exs new file mode 100644 index 00000000..513fb8b9 --- /dev/null +++ b/test/towerops_web/live/site_live/index_helpers_test.exs @@ -0,0 +1,75 @@ +defmodule ToweropsWeb.SiteLive.IndexHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.SiteLive.Index + + describe "format_qoe/1" do + test "nil returns em-dash" do + assert "—" == Index.format_qoe(nil) + end + + test "formats to 1 decimal" do + assert "4.5" == Index.format_qoe(4.5) + assert "4.0" == Index.format_qoe(4) + assert "3.1" == Index.format_qoe(3.14) + end + end + + describe "qoe_color/1" do + test "nil is gray" do + assert Index.qoe_color(nil) =~ "gray" + end + + test "< 2.0 is red" do + assert Index.qoe_color(1.9) =~ "red" + assert Index.qoe_color(0.0) =~ "red" + end + + test "2.0..4.0 is yellow" do + assert Index.qoe_color(2.0) =~ "yellow" + assert Index.qoe_color(3.9) =~ "yellow" + end + + test ">= 4.0 is green" do + assert Index.qoe_color(4.0) =~ "green" + assert Index.qoe_color(5.0) =~ "green" + end + end + + describe "health_dot/1" do + test "nil is gray" do + assert "bg-gray-400" == Index.health_dot(nil) + end + + test "atoms map to colors" do + assert "bg-red-500" == Index.health_dot(:red) + assert "bg-yellow-500" == Index.health_dot(:yellow) + assert "bg-green-500" == Index.health_dot(:green) + end + end + + describe "urgency_classes/1" do + test "known classes" do + assert Index.urgency_classes("critical") =~ "red" + assert Index.urgency_classes("warning") =~ "yellow" + assert Index.urgency_classes("info") =~ "blue" + end + + test "fallback is gray" do + assert Index.urgency_classes("foo") =~ "gray" + assert Index.urgency_classes(nil) =~ "gray" + end + end + + describe "source_classes/1" do + test "known sources" do + assert Index.source_classes("preseem") =~ "purple" + assert Index.source_classes("snmp") =~ "cyan" + assert Index.source_classes("gaiia") =~ "amber" + end + + test "fallback is gray" do + assert Index.source_classes("other") =~ "gray" + end + end +end diff --git a/test/towerops_web/live/site_live/show_helpers_test.exs b/test/towerops_web/live/site_live/show_helpers_test.exs new file mode 100644 index 00000000..f361dcb6 --- /dev/null +++ b/test/towerops_web/live/site_live/show_helpers_test.exs @@ -0,0 +1,157 @@ +defmodule ToweropsWeb.SiteLive.ShowHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.SiteLive.Show + + describe "qoe_color/1 and qoe_bg/1" do + test "nil is gray" do + assert String.contains?(Show.qoe_color(nil), "gray") + assert String.contains?(Show.qoe_bg(nil), "gray") + end + + test "high score green" do + assert String.contains?(Show.qoe_color(9.0), "green") + assert String.contains?(Show.qoe_bg(9.0), "green") + end + + test "mid score yellow" do + assert String.contains?(Show.qoe_color(7.0), "yellow") + assert String.contains?(Show.qoe_bg(7.0), "yellow") + end + + test "low score red" do + assert String.contains?(Show.qoe_color(3.0), "red") + assert String.contains?(Show.qoe_bg(3.0), "red") + end + end + + describe "capacity_bar_color/1" do + test "nil is gray" do + assert "bg-gray-300" == Show.capacity_bar_color(nil) + end + + test "high score green" do + assert "bg-green-500" == Show.capacity_bar_color(80) + end + + test "mid score yellow" do + assert "bg-yellow-500" == Show.capacity_bar_color(50) + end + + test "low score red" do + assert "bg-red-500" == Show.capacity_bar_color(20) + end + end + + describe "status_dot_class/1" do + test "up/down/other" do + assert "bg-green-500" == Show.status_dot_class(:up) + assert "bg-red-500" == Show.status_dot_class(:down) + assert "bg-gray-400" == Show.status_dot_class(:unknown) + end + end + + describe "time_ago/1" do + test "nil returns em-dash" do + assert "—" == Show.time_ago(nil) + end + + test "returns a string ending in 'ago' for past datetimes" do + dt = DateTime.add(DateTime.utc_now(), -60, :second) + assert String.ends_with?(Show.time_ago(dt), "ago") + end + end + + describe "format_response_time/1" do + test "nil returns em-dash" do + assert "—" == Show.format_response_time(nil) + end + + test "sub-ms shows <1ms" do + assert "<1ms" == Show.format_response_time(0.5) + end + + test "normal ms rounded" do + assert "42ms" == Show.format_response_time(41.7) + assert "100ms" == Show.format_response_time(100) + end + + test "non-number returns em-dash" do + assert "—" == Show.format_response_time("abc") + assert "—" == Show.format_response_time(:atom) + end + end + + describe "change_size_label/1 + change_size_color/1" do + test "Small / gray under 100" do + assert "Small" == Show.change_size_label(50) + assert String.contains?(Show.change_size_color(50), "gray") + end + + test "Medium / yellow 100-499" do + assert "Medium" == Show.change_size_label(200) + assert String.contains?(Show.change_size_color(200), "yellow") + end + + test "Large / red 500+" do + assert "Large" == Show.change_size_label(500) + assert String.contains?(Show.change_size_color(1000), "red") + end + end + + describe "insight_urgency_class/1 + insight_urgency_icon/1" do + test "critical maps to red + warning triangle" do + assert String.contains?(Show.insight_urgency_class("critical"), "red") + assert "hero-exclamation-triangle" == Show.insight_urgency_icon("critical") + end + + test "warning maps to yellow + warning circle" do + assert String.contains?(Show.insight_urgency_class("warning"), "yellow") + assert "hero-exclamation-circle" == Show.insight_urgency_icon("warning") + end + + test "other maps to blue + info circle" do + assert String.contains?(Show.insight_urgency_class("info"), "blue") + assert "hero-information-circle" == Show.insight_urgency_icon("info") + end + end + + describe "alert_severity/1" do + test "maps known alert types" do + assert "critical" == Show.alert_severity(:device_down) + assert "info" == Show.alert_severity(:device_up) + assert "warning" == Show.alert_severity(:other) + end + end + + describe "format_capacity/1" do + test "Gbps/Mbps/Kbps/bps transitions" do + assert "1.5 Gbps" == Show.format_capacity(1_500_000_000) + assert "50.0 Mbps" == Show.format_capacity(50_000_000) + assert "5.0 Kbps" == Show.format_capacity(5_000) + assert "500 bps" == Show.format_capacity(500) + end + + test "non-number returns dash" do + assert "-" == Show.format_capacity(nil) + assert "-" == Show.format_capacity("abc") + end + end + + describe "utilization_bar_color/1 and utilization_text_color/1" do + test "90%+ red" do + assert "bg-red-500" == Show.utilization_bar_color(95) + assert String.contains?(Show.utilization_text_color(95), "red") + end + + test "70-90 yellow" do + assert "bg-yellow-500" == Show.utilization_bar_color(80) + assert String.contains?(Show.utilization_text_color(80), "yellow") + end + + test "under 70 green" do + assert "bg-green-500" == Show.utilization_bar_color(40) + assert String.contains?(Show.utilization_text_color(40), "green") + end + end +end diff --git a/test/towerops_web/live/smoke_test.exs b/test/towerops_web/live/smoke_test.exs new file mode 100644 index 00000000..7a9a58d2 --- /dev/null +++ b/test/towerops_web/live/smoke_test.exs @@ -0,0 +1,223 @@ +defmodule ToweropsWeb.LiveViewSmokeTest do + @moduledoc """ + Smoke tests for LiveView pages. + + For each routed LiveView, verify that it mounts successfully and renders + without raising. These tests exist primarily for coverage of happy-path + mount/render code. + """ + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Smoke Org"}, user.id) + %{organization: organization} + end + + describe "smoke: informational pages" do + test "GET /changelog", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/changelog") + end + + test "GET /activity", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/activity") + end + + test "GET /insights", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/insights") + end + + test "GET /rf-links", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/rf-links") + end + + test "GET /reports", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/reports") + end + + test "GET /weathermap", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/weathermap") + end + + test "GET /sites-map", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/sites-map") + end + + test "GET /network-map", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/network-map") + end + end + + describe "smoke: maintenance pages" do + test "GET /maintenance", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/maintenance") + end + + test "GET /maintenance/new", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/maintenance/new") + end + end + + describe "smoke: on-call pages" do + test "GET /schedules", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/schedules") + end + + test "GET /schedules/escalation-policies", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/schedules/escalation-policies") + end + end + + describe "smoke: devices/sites" do + test "GET /sites", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/sites") + end + + test "GET /devices", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/devices") + end + end + + describe "smoke: trace/help" do + test "GET /trace", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/trace") + end + + test "GET /help", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/help") + end + end + + describe "smoke: account pages" do + test "GET /mobile/qr-login", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/mobile/qr-login") + end + end + + describe "smoke: other top-level" do + test "GET /alerts", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/alerts") + end + + test "GET /dashboard", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/dashboard") + end + end + + describe "smoke: schedule new forms" do + test "GET /schedules/new", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/schedules/new") + end + + test "GET /schedules/escalation-policies/new", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/schedules/escalation-policies/new") + end + end + + describe "smoke: sites/devices new forms" do + test "GET /sites/new", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/sites/new") + end + + test "GET /devices/new", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/devices/new") + end + end + + describe "smoke: orgs page" do + test "GET /orgs", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/orgs") + end + + test "GET /orgs/new", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/orgs/new") + end + end + + describe "smoke: specific-resource paths" do + defp assert_mount_or_redirect(result) do + assert match?({:ok, _, _}, result) or + match?({:error, {:redirect, _}}, result) or + match?({:error, {:live_redirect, _}}, result) + end + + test "GET /sites/:id", %{conn: conn, organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "S-#{System.unique_integer([:positive])}", + organization_id: org.id + }) + + assert_mount_or_redirect(live(conn, ~p"/sites/#{site.id}")) + end + + test "GET /sites/:id/edit", %{conn: conn, organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "S-#{System.unique_integer([:positive])}", + organization_id: org.id + }) + + assert_mount_or_redirect(live(conn, ~p"/sites/#{site.id}/edit")) + end + + test "GET /orgs/:id/settings", %{conn: conn, organization: org} do + assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.id}/settings")) + end + end + + describe "smoke: alert-specific paths" do + test "GET /alerts with query filter", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/alerts?severity=critical") + end + + test "GET /dashboard with tab param", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/dashboard") + end + end + + describe "smoke: extra pages" do + test "GET /trace", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/trace")) + end + + test "GET /alerts?status=resolved", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/alerts?status=resolved")) + end + + test "GET /devices?tab=monitored", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/devices?tab=monitored")) + end + + test "GET /devices?search=foo", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/devices?search=foo")) + end + + test "GET /activity?filter=all", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/activity?filter=all")) + end + + test "GET /reports?page=1", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/reports?page=1")) + end + + test "GET /rf-links?health=warning", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/rf-links?health=warning")) + end + + test "GET /insights?filter=unresolved", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/insights?filter=unresolved")) + end + + test "GET /maintenance?filter=active", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/maintenance?filter=active")) + end + + test "GET /maintenance?filter=upcoming", %{conn: conn} do + assert_mount_or_redirect(live(conn, ~p"/maintenance?filter=upcoming")) + end + end +end diff --git a/test/towerops_web/live/status_page_live_helpers_test.exs b/test/towerops_web/live/status_page_live_helpers_test.exs new file mode 100644 index 00000000..bdf50e96 --- /dev/null +++ b/test/towerops_web/live/status_page_live_helpers_test.exs @@ -0,0 +1,86 @@ +defmodule ToweropsWeb.StatusPageLiveHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.StatusPageLive + + describe "overall_color/1" do + test "maps each status to a tailwind background class" do + assert "bg-green-500" == StatusPageLive.overall_color(:operational) + assert "bg-yellow-500" == StatusPageLive.overall_color(:degraded) + assert "bg-orange-500" == StatusPageLive.overall_color(:partial_outage) + assert "bg-red-500" == StatusPageLive.overall_color(:major_outage) + assert "bg-blue-500" == StatusPageLive.overall_color(:maintenance) + end + + test "unknown status falls back to gray" do + assert "bg-gray-500" == StatusPageLive.overall_color(:mystery) + assert "bg-gray-500" == StatusPageLive.overall_color(nil) + end + end + + describe "overall_text/1" do + test "maps each status to a user-facing label" do + assert "All Systems Operational" == StatusPageLive.overall_text(:operational) + assert "Degraded Performance" == StatusPageLive.overall_text(:degraded) + assert "Partial System Outage" == StatusPageLive.overall_text(:partial_outage) + assert "Major System Outage" == StatusPageLive.overall_text(:major_outage) + assert "Scheduled Maintenance" == StatusPageLive.overall_text(:maintenance) + end + + test "unknown status labeled Unknown" do + assert "Unknown" == StatusPageLive.overall_text(nil) + assert "Unknown" == StatusPageLive.overall_text(:whatever) + end + end + + describe "component_color/1 (string keys)" do + test "maps each status" do + assert "bg-green-500" == StatusPageLive.component_color("operational") + assert "bg-yellow-500" == StatusPageLive.component_color("degraded") + assert "bg-orange-500" == StatusPageLive.component_color("partial_outage") + assert "bg-red-500" == StatusPageLive.component_color("major_outage") + assert "bg-blue-500" == StatusPageLive.component_color("maintenance") + end + + test "unknown falls back to gray-400" do + assert "bg-gray-400" == StatusPageLive.component_color("unknown") + assert "bg-gray-400" == StatusPageLive.component_color(nil) + end + end + + describe "component_label/1" do + test "humanizes known labels" do + assert "Operational" == StatusPageLive.component_label("operational") + assert "Degraded" == StatusPageLive.component_label("degraded") + assert "Partial Outage" == StatusPageLive.component_label("partial_outage") + assert "Major Outage" == StatusPageLive.component_label("major_outage") + assert "Maintenance" == StatusPageLive.component_label("maintenance") + end + + test "unknown labels pass through" do + assert "foobar" == StatusPageLive.component_label("foobar") + end + end + + describe "severity_color/1" do + test "critical, major, other" do + assert "text-red-600" == StatusPageLive.severity_color("critical") + assert "text-orange-600" == StatusPageLive.severity_color("major") + assert "text-yellow-600" == StatusPageLive.severity_color("minor") + assert "text-yellow-600" == StatusPageLive.severity_color(nil) + end + end + + describe "incident_status_label/1" do + test "maps each workflow state" do + assert "Investigating" == StatusPageLive.incident_status_label("investigating") + assert "Identified" == StatusPageLive.incident_status_label("identified") + assert "Monitoring" == StatusPageLive.incident_status_label("monitoring") + assert "Resolved" == StatusPageLive.incident_status_label("resolved") + end + + test "unknown labels pass through" do + assert "weird" == StatusPageLive.incident_status_label("weird") + end + end +end diff --git a/test/towerops_web/live/trace_live/index_helpers_test.exs b/test/towerops_web/live/trace_live/index_helpers_test.exs new file mode 100644 index 00000000..92c9fa28 --- /dev/null +++ b/test/towerops_web/live/trace_live/index_helpers_test.exs @@ -0,0 +1,225 @@ +defmodule ToweropsWeb.TraceLive.IndexHelpersTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.TraceLive.Index + + describe "type_badge_class/1 + type_badge_label/1" do + test "known types" do + for {type, class, label} <- [ + {:account, "badge-primary", "Account"}, + {:inventory_item, "badge-secondary", "Inventory"}, + {:site, "badge-warning", "Site"}, + {:device, "badge-accent", "Device"}, + {:access_point, "badge-info", "AP"} + ] do + assert class == Index.type_badge_class(type) + assert label == Index.type_badge_label(type) + end + end + + test "unknown falls through to ghost/Unknown" do + assert "badge-ghost" == Index.type_badge_class(:other) + assert "Unknown" == Index.type_badge_label(:other) + end + end + + describe "status_badge_class/1" do + test "active/suspended/cancelled" do + assert "badge-success" == Index.status_badge_class("active") + assert "badge-warning" == Index.status_badge_class("suspended") + assert "badge-error" == Index.status_badge_class("cancelled") + end + + test "unknown is ghost" do + assert "badge-ghost" == Index.status_badge_class(nil) + assert "badge-ghost" == Index.status_badge_class("other") + end + end + + describe "device_status_color/1 + device_status_dot/1" do + test "up/down/other" do + assert String.contains?(Index.device_status_color(:up), "green") + assert String.contains?(Index.device_status_color(:down), "red") + assert String.contains?(Index.device_status_color(nil), "gray") + + assert "bg-green-500" == Index.device_status_dot(:up) + assert "bg-red-500" == Index.device_status_dot(:down) + assert "bg-gray-400" == Index.device_status_dot(:other) + end + end + + describe "format_speed/1" do + test "nil" do + assert "—" == Index.format_speed(nil) + end + + test "< 1000 Mbps stays as Mbps" do + assert "500 Mbps" == Index.format_speed(500) + end + + test ">= 1000 Mbps becomes Gbps" do + assert "1.5 Gbps" == Index.format_speed(1500) + end + + test "non-numeric passes through" do + assert "" == Index.format_speed("") + end + end + + describe "format_relative_time/1" do + test "nil returns em-dash" do + assert "—" == Index.format_relative_time(nil) + end + + test "returns relative string for past datetime" do + dt = DateTime.add(DateTime.utc_now(), -120, :second) + result = Index.format_relative_time(dt) + assert String.contains?(result, "m") or result == "just now" + end + end + + describe "format_score/1 + format_pct/1 + format_ms/1" do + test "nil returns em-dash" do + assert "—" == Index.format_score(nil) + assert "—" == Index.format_pct(nil) + assert "—" == Index.format_ms(nil) + end + + test "numeric formatted with 1 decimal" do + assert "50.0" == Index.format_score(50) + assert "75.5%" == Index.format_pct(75.5) + assert "12.3 ms" == Index.format_ms(12.345) + end + end + + describe "format_throughput/1" do + test "nil" do + assert "—" == Index.format_throughput(nil) + end + + test "< 1000 Mbps" do + assert "100.0 Mbps" == Index.format_throughput(100) + end + + test ">= 1000 becomes Gbps" do + assert "1.5 Gbps" == Index.format_throughput(1500) + end + end + + describe "format_alert_type/1" do + test "nil returns em-dash" do + assert "—" == Index.format_alert_type(nil) + end + + test "humanizes snake_case" do + assert "Device down" == Index.format_alert_type("device_down") + assert "Packet loss" == Index.format_alert_type("packet_loss") + end + + test "non-binary stringified" do + assert "42" == Index.format_alert_type(42) + end + end + + describe "score_quality/1 + score_color/1" do + test "nil is unknown/gray" do + assert :unknown == Index.score_quality(nil) + assert "text-gray-400" == Index.score_color(nil) + end + + test ">= 80 is good/green" do + assert :good == Index.score_quality(90) + assert String.contains?(Index.score_color(90), "green") + end + + test "50-80 is warning/yellow" do + assert :warning == Index.score_quality(60) + assert String.contains?(Index.score_color(60), "yellow") + end + + test "< 50 is bad/red" do + assert :bad == Index.score_quality(30) + assert String.contains?(Index.score_color(30), "red") + end + end + + describe "latency_color/1, jitter_color/1, loss_color/1" do + test "all return gray for nil" do + assert "text-gray-400" == Index.latency_color(nil) + assert "text-gray-400" == Index.jitter_color(nil) + assert "text-gray-400" == Index.loss_color(nil) + end + + test "latency thresholds" do + assert String.contains?(Index.latency_color(10), "green") + assert String.contains?(Index.latency_color(50), "yellow") + assert String.contains?(Index.latency_color(200), "red") + end + + test "jitter thresholds" do + assert String.contains?(Index.jitter_color(5), "green") + assert String.contains?(Index.jitter_color(20), "yellow") + assert String.contains?(Index.jitter_color(50), "red") + end + + test "loss thresholds" do + assert String.contains?(Index.loss_color(0), "green") + assert String.contains?(Index.loss_color(3), "yellow") + assert String.contains?(Index.loss_color(10), "red") + end + end + + describe "alert_dot/1" do + test "critical severity" do + assert "bg-red-500" == Index.alert_dot(%{severity: "critical"}) + end + + test "warning severity" do + assert "bg-yellow-500" == Index.alert_dot(%{severity: "warning"}) + end + + test "alert_type down" do + assert "bg-red-500" == Index.alert_dot(%{alert_type: "down"}) + end + + test "fallback" do + assert "bg-orange-400" == Index.alert_dot(%{}) + end + end + + describe "urgency_dot/1" do + test "maps known values" do + assert "bg-red-500" == Index.urgency_dot("critical") + assert "bg-orange-500" == Index.urgency_dot("high") + assert "bg-yellow-500" == Index.urgency_dot("medium") + assert "bg-blue-400" == Index.urgency_dot("other") + end + end + + describe "airtime_quality/1" do + test "nil is unknown" do + assert :unknown == Index.airtime_quality(nil) + end + + test "thresholds" do + assert :good == Index.airtime_quality(40) + assert :warning == Index.airtime_quality(70) + assert :bad == Index.airtime_quality(90) + end + end + + describe "safe_type/1" do + test "maps known strings to atoms" do + assert :account == Index.safe_type("account") + assert :inventory_item == Index.safe_type("inventory_item") + assert :site == Index.safe_type("site") + assert :device == Index.safe_type("device") + assert :access_point == Index.safe_type("access_point") + end + + test "unknown defaults to :account" do + assert :account == Index.safe_type("garbage") + assert :account == Index.safe_type(nil) + end + end +end diff --git a/test/towerops_web/telemetry_test.exs b/test/towerops_web/telemetry_test.exs new file mode 100644 index 00000000..a6ad2bca --- /dev/null +++ b/test/towerops_web/telemetry_test.exs @@ -0,0 +1,53 @@ +defmodule ToweropsWeb.TelemetryTest do + use ExUnit.Case, async: true + + alias ToweropsWeb.Telemetry + + describe "parse_redis_info/1" do + test "parses a minimal Redis INFO response" do + info = """ + # Stats + total_connections_received:12 + total_commands_processed:500 + instantaneous_ops_per_sec:5 + """ + + result = Telemetry.parse_redis_info(info) + assert result["total_connections_received"] == "12" + assert result["total_commands_processed"] == "500" + assert result["instantaneous_ops_per_sec"] == "5" + end + + test "skips comment/empty lines" do + info = "\n# Comment\n\nkey:value\n# Another\n" + assert %{"key" => "value"} == Telemetry.parse_redis_info(info) + end + + test "returns empty map for empty input" do + assert %{} == Telemetry.parse_redis_info("") + end + + test "handles CRLF line endings" do + info = "a:1\r\nb:2\r\n" + assert %{"a" => "1", "b" => "2"} == Telemetry.parse_redis_info(info) + end + + test "splits on first colon only" do + info = "url:redis://localhost:6379" + assert %{"url" => "redis://localhost:6379"} == Telemetry.parse_redis_info(info) + end + + test "drops malformed entries without colon" do + info = "valid:1\njunk_no_colon\nanother:2" + assert %{"valid" => "1", "another" => "2"} == Telemetry.parse_redis_info(info) + end + end + + describe "metrics/0" do + test "returns a list of metric specs" do + metrics = Telemetry.metrics() + assert is_list(metrics) + refute Enum.empty?(metrics) + end + end +end