test: cut runtime on slow tests, fix time-sensitive flake

Performance:
- HealthController: honor `:timeout` from `:redis` config (was hardcoded
  2s) so the 503-when-unreachable test exits immediately
- DevicePollerBranches: drop `collect_events` window 1500ms → 100ms
  (PubSub broadcasts are synchronous from `run_perform`)
- Towerops.Unused: cache xref analysis in `:persistent_term` keyed by
  BEAM mtimes so repeated `mix unused` invocations skip re-analysis
- DiscoveryWorker: make wait_loop interval configurable via
  `:discovery_wait_interval_ms` (25ms in tests vs 500ms in prod)
- ProfileWatcher: configurable `:profile_reloader` so tests can stub
  the YAML reload instead of paying the full disk-load cost
- PingExecutor: tag the loopback test `:network` (it shells out to
  `ping`); coverage already provided by 4 other `:network`-tagged tests

Flake fix:
- FrequencyChange tests used hardcoded `~U[2026-05-09 12:00:00Z]`,
  which now falls outside the rule's 24h lookback window. Switched to
  `DateTime.utc_now()` so scans stay inside the window regardless of
  clock progression.
This commit is contained in:
Graham McIntire 2026-05-10 10:39:36 -05:00
parent 6a8ee862bd
commit b1c803e1d2
9 changed files with 71 additions and 12 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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-

View file

@ -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 ->

View file

@ -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
)

View file

@ -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"