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.
139 lines
5.1 KiB
Elixir
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
|