prop/test/microwaveprop/beacon_measurements_test.exs
Graham McIntire bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00

139 lines
5.1 KiB
Elixir

defmodule Microwaveprop.BeaconMeasurementsTest do
use Microwaveprop.DataCase
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.BeaconMeasurements.BeaconMeasurement
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.Beacons
defp setup_actors(_) do
user = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Shack Pi"})
beacon = beacon_fixture(user)
{:ok, beacon} = Beacons.approve_beacon(beacon)
%{user: user, monitor: monitor, beacon: beacon}
end
defp wire_attrs(beacon, overrides \\ %{}) do
Map.merge(
%{
"beacon_id" => beacon.id,
"frequency_hz" => 28_330_000,
"measured_at" => "2026-05-13T15:30:00Z",
"integration_s" => 60,
"passband_hz" => 300.0,
"gain_db" => 10.0,
"noise_floor_dbfs" => -110.2,
"signal_peak_dbfs" => -88.4,
"signal_avg_dbfs" => -89.1,
"snr_peak_db" => 21.8,
"snr_avg_db" => 21.1,
"signal_active_fraction" => 0.48,
"propmonitor_version" => "0.1.0"
},
overrides
)
end
describe "create_measurement/2" do
setup :setup_actors
test "inserts a measurement for an approved on-air beacon", %{monitor: m, beacon: b} do
assert {:ok, %BeaconMeasurement{} = meas} =
BeaconMeasurements.create_measurement(m, wire_attrs(b))
assert meas.monitor_id == m.id
assert meas.beacon_id == b.id
assert meas.frequency_hz == 28_330_000
assert meas.signal_active_fraction == 0.48
assert meas.propmonitor_version == "0.1.0"
end
test "returns :beacon_not_found for an unknown beacon UUID", %{monitor: m} do
attrs = wire_attrs(%{id: "00000000-0000-0000-0000-000000000000"})
assert {:error, :beacon_not_found} =
BeaconMeasurements.create_measurement(m, attrs)
end
test "returns :beacon_not_found for a malformed beacon UUID", %{monitor: m, beacon: b} do
attrs = b |> wire_attrs() |> Map.put("beacon_id", "not-a-uuid")
assert {:error, :beacon_not_found} = BeaconMeasurements.create_measurement(m, attrs)
end
test "returns :beacon_not_found for an unapproved beacon", %{monitor: m, user: u} do
pending = beacon_fixture(u, callsign: "K5HOLD")
attrs = wire_attrs(pending)
assert {:error, :beacon_not_found} =
BeaconMeasurements.create_measurement(m, attrs)
end
test "returns :beacon_not_found for a beacon marked off-the-air", %{monitor: m, beacon: b} do
{:ok, _} = Beacons.update_beacon(b, %{"on_the_air" => false})
assert {:error, :beacon_not_found} = BeaconMeasurements.create_measurement(m, wire_attrs(b))
end
test "rejects out-of-range signal_active_fraction", %{monitor: m, beacon: b} do
attrs = wire_attrs(b, %{"signal_active_fraction" => 1.5})
assert {:error, changeset} = BeaconMeasurements.create_measurement(m, attrs)
assert %{signal_active_fraction: [_ | _]} = errors_on(changeset)
end
test "rejects integration_s below the floor", %{monitor: m, beacon: b} do
attrs = wire_attrs(b, %{"integration_s" => 1})
assert {:error, changeset} = BeaconMeasurements.create_measurement(m, attrs)
assert %{integration_s: [_ | _]} = errors_on(changeset)
end
test "rejects missing required fields", %{monitor: m, beacon: b} do
attrs = Map.delete(wire_attrs(b), "noise_floor_dbfs")
assert {:error, changeset} = BeaconMeasurements.create_measurement(m, attrs)
assert %{noise_floor_dbfs: ["can't be blank"]} = errors_on(changeset)
end
test "duplicate (monitor, beacon, measured_at) returns a unique-constraint error",
%{monitor: m, beacon: b} do
attrs = wire_attrs(b)
assert {:ok, _} = BeaconMeasurements.create_measurement(m, attrs)
assert {:error, %Ecto.Changeset{} = changeset} =
BeaconMeasurements.create_measurement(m, attrs)
assert Enum.any?(changeset.errors, fn {_, {_, opts}} ->
Keyword.get(opts, :constraint) == :unique
end)
end
test "two monitors can report for the same beacon at the same instant",
%{monitor: m, beacon: b, user: u} do
{:ok, m2} = BeaconMonitors.create_monitor(u, %{"name" => "Other Pi"})
attrs = wire_attrs(b)
assert {:ok, _} = BeaconMeasurements.create_measurement(m, attrs)
assert {:ok, _} = BeaconMeasurements.create_measurement(m2, attrs)
end
end
describe "list_recent_for_beacon/2 and list_recent_for_monitor/2" do
setup :setup_actors
test "returns measurements newest first", %{monitor: m, beacon: b} do
a1 = wire_attrs(b, %{"measured_at" => "2026-05-13T15:30:00Z"})
a2 = wire_attrs(b, %{"measured_at" => "2026-05-13T15:31:00Z"})
{:ok, _} = BeaconMeasurements.create_measurement(m, a1)
{:ok, _} = BeaconMeasurements.create_measurement(m, a2)
[latest, prev] = BeaconMeasurements.list_recent_for_beacon(b.id, 10)
assert latest.measured_at > prev.measured_at
[latest_m, _] = BeaconMeasurements.list_recent_for_monitor(m.id, 10)
assert latest_m.measured_at == latest.measured_at
end
end
end