From 58e6fcff0d32db42d807c8560de833cfd9a8bfc6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 14:34:20 -0500 Subject: [PATCH] test: cover propagation/freshness_monitor 10 characterization tests: fresh vs stale thresholds (>120 min), boundary behavior, empty scores dir, supervised start, non-dedup of repeated stale ticks, :ignore when disabled via config. --- .../propagation/freshness_monitor_test.exs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 test/microwaveprop/propagation/freshness_monitor_test.exs diff --git a/test/microwaveprop/propagation/freshness_monitor_test.exs b/test/microwaveprop/propagation/freshness_monitor_test.exs new file mode 100644 index 00000000..414011b2 --- /dev/null +++ b/test/microwaveprop/propagation/freshness_monitor_test.exs @@ -0,0 +1,164 @@ +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 each enqueue another job" do + # Characterization note: the module comment claims an Oban unique + # constraint on PropagationGridWorker suppresses duplicates, but + # the worker currently declares no `unique:` option. Each tick + # therefore inserts a fresh job. + 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 3 == 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