defmodule Microwaveprop.Propagation.FreshnessMonitorTest do @moduledoc """ Characterization tests for `FreshnessMonitor`. The monitor is a simple GenServer that polls propagation score freshness on a 5-minute timer. On each tick it reads `Propagation.latest_valid_time/0` (which reads `ScoresFile` on disk) and enqueues a `PropagationGridWorker` job when either no scores exist yet or the newest score file is older than 120 minutes. These tests drive the tick path synchronously by invoking `handle_info(:check, state)` from the test process inside `Oban.Testing.with_testing_mode(:manual, ...)` — that keeps `Oban.insert` from executing the job inline, letting us assert on enqueued jobs via `all_enqueued/1`. One smoke test exercises the supervised startup path with a fresh ScoresFile so no enqueue is attempted. The interval (`@check_interval = 5 min`) and staleness threshold (`@stale_threshold_minutes = 120`) are compile-time module attributes with no runtime override, so we can't shrink the interval — we exercise the tick directly instead. """ use Microwaveprop.DataCase, async: false use Oban.Testing, repo: Microwaveprop.Repo alias Microwaveprop.Propagation.FreshnessMonitor alias Microwaveprop.Propagation.ScoresFile alias Microwaveprop.Workers.PropagationGridWorker @band_mhz 10_000 @sample_scores [%{lat: 25.0, lon: -125.0, score: 50}] defp minutes_ago(n) do DateTime.utc_now() |> DateTime.add(-n, :minute) |> DateTime.truncate(:second) end describe "handle_info(:check, state) — characterization of tick behavior" do test "fresh grid: does not enqueue a PropagationGridWorker job" do ScoresFile.write!(@band_mhz, minutes_ago(15), @sample_scores) Oban.Testing.with_testing_mode(:manual, fn -> assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{}) assert [] = all_enqueued(worker: PropagationGridWorker) end) end test "stale grid (>120m old): enqueues a PropagationGridWorker job" do ScoresFile.write!(@band_mhz, minutes_ago(180), @sample_scores) Oban.Testing.with_testing_mode(:manual, fn -> assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{}) [job] = all_enqueued(worker: PropagationGridWorker) assert job.args == %{} assert job.queue == "propagation" end) end test "just past threshold (121m old): enqueues" do ScoresFile.write!(@band_mhz, minutes_ago(121), @sample_scores) Oban.Testing.with_testing_mode(:manual, fn -> assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{}) assert [_job] = all_enqueued(worker: PropagationGridWorker) end) end test "exactly at threshold (120m old): does NOT enqueue (strict `>` comparison)" do ScoresFile.write!(@band_mhz, minutes_ago(120), @sample_scores) Oban.Testing.with_testing_mode(:manual, fn -> assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{}) assert [] = all_enqueued(worker: PropagationGridWorker) end) end test "empty grid (no score files): enqueues a PropagationGridWorker job" do # DataCase.reset_score_files/0 wipes the scores dir, so nothing exists. Oban.Testing.with_testing_mode(:manual, fn -> assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{}) [job] = all_enqueued(worker: PropagationGridWorker) assert job.args == %{} end) end test "returns unchanged state map from the tick" do Oban.Testing.with_testing_mode(:manual, fn -> assert {:noreply, %{foo: :bar}} = FreshnessMonitor.handle_info(:check, %{foo: :bar}) end) end test "repeated ticks on a persistently stale grid are deduplicated into a single job" do # PropagationGridWorker declares `unique:` on the seed args (`%{}`), # so a stale-check tick that runs while an earlier seed is still # available/scheduled/executing/retryable is a no-op. Without this, # a long outage stacks one expensive HRRR f00-f18 chain per 5-min # tick (e.g. a 2-hour outage = 24 redundant seed jobs). ScoresFile.write!(@band_mhz, minutes_ago(180), @sample_scores) Oban.Testing.with_testing_mode(:manual, fn -> FreshnessMonitor.handle_info(:check, %{}) FreshnessMonitor.handle_info(:check, %{}) FreshnessMonitor.handle_info(:check, %{}) assert 1 == length(all_enqueued(worker: PropagationGridWorker)) end) end test "tick does not synchronously deliver the next :check message (it is scheduled with a 5-minute delay)" do # Characterizes the `Process.send_after(self(), :check, @check_interval)` # call at the end of handle_info/2: the follow-up :check is # scheduled, not sent immediately, so the test mailbox stays # empty. ScoresFile.write!(@band_mhz, minutes_ago(15), @sample_scores) Oban.Testing.with_testing_mode(:manual, fn -> FreshnessMonitor.handle_info(:check, %{}) refute_received :check end) end end describe "start_link/1 — supervision lifecycle" do test "does not start when start_freshness_monitor config is false (test default)" do # In test.exs the flag is false, so start_link returns :ignore # and no process is registered. assert :ignore = FreshnessMonitor.start_link([]) assert Process.whereis(FreshnessMonitor) == nil end test "supervised start: boots cleanly with a fresh ScoresFile and no enqueue occurs" do ScoresFile.write!(@band_mhz, minutes_ago(15), @sample_scores) # Enable the start flag for this test only. original = Application.get_env(:microwaveprop, :start_freshness_monitor) Application.put_env(:microwaveprop, :start_freshness_monitor, true) on_exit(fn -> if is_nil(original) do Application.delete_env(:microwaveprop, :start_freshness_monitor) else Application.put_env(:microwaveprop, :start_freshness_monitor, original) end end) pid = start_supervised!(FreshnessMonitor) assert is_pid(pid) assert Process.alive?(pid) assert Process.whereis(FreshnessMonitor) == pid # Force the init-time :check to be processed by issuing a # synchronous call; GenServer.call flushes the mailbox up to # this point. FreshnessMonitor doesn't implement handle_call, # so use :sys.get_state instead — it waits for all pending # messages to be handled. assert %{} == :sys.get_state(pid) end end end