diff --git a/config/test.exs b/config/test.exs index 00d2c19a..5b00440d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -125,5 +125,7 @@ config :towerops, agent_poll_interval_ms: 100, # Reduce device deletion delay for faster tests (10ms vs 500ms in prod) device_deletion_delay_ms: 10, + # Reduce DiscoveryWorker poll cadence (25ms vs 500ms in prod) + discovery_wait_interval_ms: 25, # Stripe test configuration stripe_product_id: "prod_test" diff --git a/lib/towerops/profiles/profile_watcher.ex b/lib/towerops/profiles/profile_watcher.ex index 47863ec8..26f638ef 100644 --- a/lib/towerops/profiles/profile_watcher.ex +++ b/lib/towerops/profiles/profile_watcher.ex @@ -53,10 +53,15 @@ defmodule Towerops.Profiles.ProfileWatcher do if yaml_file?(path) and should_reload?(events) do Logger.info("ProfileWatcher: Detected change in #{Path.relative_to_cwd(path)}, reloading profiles...") - case YamlProfiles.reload() do + reloader = Application.get_env(:towerops, :profile_reloader, YamlProfiles) + + case reloader.reload() do {:ok, profiles} when is_list(profiles) -> Logger.info("ProfileWatcher: Successfully reloaded #{length(profiles)} profiles") + {:ok, count} when is_integer(count) -> + Logger.info("ProfileWatcher: Successfully reloaded #{count} profiles") + {:error, reason} -> Logger.error("ProfileWatcher: Failed to reload profiles: #{inspect(reason)}") end diff --git a/lib/towerops/unused.ex b/lib/towerops/unused.ex index 6f97a95c..9c43d6ee 100644 --- a/lib/towerops/unused.ex +++ b/lib/towerops/unused.ex @@ -32,11 +32,39 @@ defmodule Towerops.Unused do @spec unused() :: [{module(), atom(), arity(), Path.t(), pos_integer()}] def unused do case beam_paths() do - [] -> [] - beams -> analyse_beams(beams) + [] -> + [] + + beams -> + # `:xref` analysis is expensive (~1s) and pure for a fixed set of + # BEAM files. Cache by the (sorted) list of BEAM mtimes so multiple + # callers in the same OS process — most often `mix unused` followed + # by `mix unused --format json` in tests — reuse one analysis pass. + key = cache_key(beams) + + case :persistent_term.get({__MODULE__, :unused_cache}, nil) do + {^key, cached} -> + cached + + _ -> + result = analyse_beams(beams) + :persistent_term.put({__MODULE__, :unused_cache}, {key, result}) + result + end end end + defp cache_key(beams) do + beams + |> Enum.sort() + |> Enum.map(fn beam -> + case File.stat(beam, time: :posix) do + {:ok, %File.Stat{mtime: m, size: s}} -> {beam, m, s} + _ -> {beam, 0, 0} + end + end) + end + @doc false @spec keep?({module(), atom(), arity()}) :: boolean() def keep?({_mod, fun, arity} = mfa) do diff --git a/lib/towerops/workers/discovery_worker.ex b/lib/towerops/workers/discovery_worker.ex index 24fe7fb0..0149542a 100644 --- a/lib/towerops/workers/discovery_worker.ex +++ b/lib/towerops/workers/discovery_worker.ex @@ -518,8 +518,8 @@ defmodule Towerops.Workers.DiscoveryWorker do elapsed ) do if device.last_discovery_at == initial_discovery_at do - # Wait 500ms before checking again - Process.sleep(500) + # Wait before checking again (500ms in prod; tests override to ~25ms) + Process.sleep(Application.get_env(:towerops, :discovery_wait_interval_ms, 500)) wait_loop(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms) else Logger.info( diff --git a/lib/towerops_web/controllers/health_controller.ex b/lib/towerops_web/controllers/health_controller.ex index 579188f9..aa800d77 100644 --- a/lib/towerops_web/controllers/health_controller.ex +++ b/lib/towerops_web/controllers/health_controller.ex @@ -57,7 +57,9 @@ defmodule ToweropsWeb.HealthController do if Keyword.has_key?(redis_config, :host) do # Redis is configured, check connectivity with short timeout for health check - case Towerops.RedisHealthCheck.check_health(redis_config, 2_000) do + timeout = Keyword.get(redis_config, :timeout, 2_000) + + case Towerops.RedisHealthCheck.check_health(redis_config, timeout) do :ok -> :ok diff --git a/test/towerops/monitoring/executors/ping_executor_test.exs b/test/towerops/monitoring/executors/ping_executor_test.exs index 0a620a67..e2396de2 100644 --- a/test/towerops/monitoring/executors/ping_executor_test.exs +++ b/test/towerops/monitoring/executors/ping_executor_test.exs @@ -62,6 +62,7 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do assert is_binary(reason) end + @tag :network test "exercises run_ping path against loopback (no network required)" do # 127.0.0.1 is always reachable via loopback; this drives the # System.cmd/Task.async/Task.yield branches that the :network- diff --git a/test/towerops/profiles/profile_watcher_test.exs b/test/towerops/profiles/profile_watcher_test.exs index 8b04b6d8..e669f4d3 100644 --- a/test/towerops/profiles/profile_watcher_test.exs +++ b/test/towerops/profiles/profile_watcher_test.exs @@ -1,3 +1,8 @@ +defmodule FakeReloader do + @moduledoc false + def reload, do: {:ok, 0} +end + defmodule Towerops.Profiles.ProfileWatcherTest do use ExUnit.Case, async: true @@ -95,6 +100,18 @@ defmodule Towerops.Profiles.ProfileWatcherTest do end test "yaml file event with reload trigger doesn't crash and preserves state" do + # Stub out the reloader so we don't pay the full YamlProfiles.reload + # cost (loads ~hundreds of YAML files from disk) just to verify the + # handle_info branch. + previous = Application.get_env(:towerops, :profile_reloader) + Application.put_env(:towerops, :profile_reloader, FakeReloader) + + on_exit(fn -> + if previous, + do: Application.put_env(:towerops, :profile_reloader, previous), + else: Application.delete_env(:towerops, :profile_reloader) + end) + msg = {:file_event, self(), {"priv/profiles/test.yaml", [:modified]}} ExUnit.CaptureLog.capture_log(fn -> diff --git a/test/towerops/recommendations/rules/frequency_change_test.exs b/test/towerops/recommendations/rules/frequency_change_test.exs index 588fd07c..a9c74f73 100644 --- a/test/towerops/recommendations/rules/frequency_change_test.exs +++ b/test/towerops/recommendations/rules/frequency_change_test.exs @@ -22,6 +22,10 @@ defmodule Towerops.Recommendations.Rules.FrequencyChangeTest do org = organization_fixture(user.id) device = device_fixture(%{organization_id: org.id, name: "AP-North"}) + # Use `now` so scans always fall inside the rule's 24h lookback — + # hardcoded dates here would silently age out of the window. + now = DateTime.truncate(DateTime.utc_now(), :second) + {:ok, snmp} = %SnmpDevice{} |> SnmpDevice.changeset(%{ @@ -30,11 +34,11 @@ defmodule Towerops.Recommendations.Rules.FrequencyChangeTest do current_channel: 149, current_frequency_mhz: 5745, current_channel_width_mhz: 80, - last_radio_seen_at: ~U[2026-05-09 12:00:00Z] + last_radio_seen_at: now }) |> Repo.insert() - %{org: org, device: device, snmp: snmp} + %{org: org, device: device, snmp: snmp, now: now} end defp insert_scan(org, device, attrs) do @@ -49,7 +53,7 @@ defmodule Towerops.Recommendations.Rules.FrequencyChangeTest do channel_width_mhz: 80, rssi_dbm: -70, is_own_fleet: false, - seen_at: ~U[2026-05-09 12:00:00Z] + seen_at: DateTime.truncate(DateTime.utc_now(), :second) }, attrs ) diff --git a/test/towerops/workers/device_poller_worker_branches_test.exs b/test/towerops/workers/device_poller_worker_branches_test.exs index 476dde42..f162cea7 100644 --- a/test/towerops/workers/device_poller_worker_branches_test.exs +++ b/test/towerops/workers/device_poller_worker_branches_test.exs @@ -415,7 +415,7 @@ defmodule Towerops.Workers.DevicePollerWorkerBranchesTest do assert :ok = run_perform(device) - received_events = collect_events([], 1_500) + received_events = collect_events([], 100) assert Enum.any?(received_events, fn e -> Map.get(e, :event_type) == "interface_speed_change" @@ -471,7 +471,7 @@ defmodule Towerops.Workers.DevicePollerWorkerBranchesTest do assert :ok = run_perform(device) - received_events = collect_events([], 1_500) + received_events = collect_events([], 100) assert Enum.any?(received_events, fn e -> Map.get(e, :event_type) == "interface_mac_change" @@ -527,7 +527,7 @@ defmodule Towerops.Workers.DevicePollerWorkerBranchesTest do assert :ok = run_perform(device) - events = collect_events([], 1_500) + events = collect_events([], 100) assert Enum.any?(events, fn e -> Map.get(e, :event_type) == "interface_up"