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.
This commit is contained in:
Graham McIntire 2026-05-13 16:07:04 -05:00
parent c6adc989e9
commit bd731685de
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 771 additions and 0 deletions

View file

@ -180,6 +180,36 @@ paths:
description: No content
"404": { $ref: "#/components/responses/NotFound" }
/beacon-monitor/measurements:
post:
tags: [monitors]
summary: Upload a beacon-signal-level measurement
description: |
Ingest endpoint for the `propmonitor` client. The Bearer token here
is the `BeaconMonitor` token (issued once by
`POST /me/beacon-monitors`), not a user API token. One POST per
integration window per monitor; payloads are never batched.
`signal_peak_dbfs`, `signal_avg_dbfs`, and the `snr_*` fields are
computed only over FFT frames whose in-band power exceeded
`noise_floor + 3 dB`. `signal_active_fraction` reports the duty
cycle of those frames over the window.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMeasurementCreate" }
responses:
"204":
description: Accepted and recorded. The monitor's `last_seen_at` is updated.
"401": { $ref: "#/components/responses/Unauthorized" }
"404":
description: The supplied `beacon_id` is unknown, unapproved, or off-the-air.
"409":
description: A measurement for this monitor + beacon + measured_at already exists.
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
/contacts:
get:
tags: [contacts]
@ -661,6 +691,74 @@ components:
type: array
items: { $ref: "#/components/schemas/BeaconMonitor" }
BeaconMeasurementCreate:
type: object
required:
- beacon_id
- frequency_hz
- measured_at
- integration_s
- passband_hz
- gain_db
- noise_floor_dbfs
- signal_peak_dbfs
- signal_avg_dbfs
- snr_peak_db
- snr_avg_db
- signal_active_fraction
- propmonitor_version
properties:
beacon_id:
type: string
format: uuid
description: The beacon this measurement is for.
frequency_hz:
type: integer
minimum: 1
description: SDR tuned center frequency in Hz.
measured_at:
type: string
format: date-time
description: UTC ISO-8601 timestamp at the start of the integration window.
integration_s:
type: integer
minimum: 5
maximum: 3600
description: Integration window length in seconds.
passband_hz:
type: number
exclusiveMinimum: 0
description: Bandwidth of the in-band power window.
gain_db:
type: number
minimum: -10
maximum: 80
description: SDR-reported gain at upload time.
noise_floor_dbfs:
type: number
description: Median out-of-passband bin power, dBFS.
signal_peak_dbfs:
type: number
description: Peak in-passband power across detected-on-air frames, dBFS.
signal_avg_dbfs:
type: number
description: Mean in-passband power across detected-on-air frames, dBFS.
snr_peak_db:
type: number
description: signal_peak_dbfs minus noise_floor_dbfs.
snr_avg_db:
type: number
description: signal_avg_dbfs minus noise_floor_dbfs.
signal_active_fraction:
type: number
minimum: 0
maximum: 1
description: Fraction of FFT frames where in-band power exceeded noise_floor + 3 dB.
propmonitor_version:
type: string
maxLength: 32
description: Build version of the reporting client (diagnostic).
ProfileResponse:
type: object
properties:

View file

@ -0,0 +1,109 @@
defmodule Microwaveprop.BeaconMeasurements do
@moduledoc """
The BeaconMeasurements context: ingests beacon-signal-level
measurements uploaded by propmonitor clients.
See `Microwaveprop.BeaconMeasurements.BeaconMeasurement` for the
schema and field semantics.
"""
import Ecto.Query
alias Microwaveprop.BeaconMeasurements.BeaconMeasurement
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@doc """
Inserts a measurement for the given monitor. `attrs` is expected to
carry the wire fields from `POST /api/v1/beacon-monitor/measurements`
(including `beacon_id` as a UUID string).
Returns `{:error, :beacon_not_found}` if the beacon UUID does not
resolve to an approved on-the-air beacon the propmonitor client
drops the measurement on this, so we keep the differentiation from
generic 422s.
"""
@spec create_measurement(BeaconMonitor.t(), map()) ::
{:ok, BeaconMeasurement.t()}
| {:error, :beacon_not_found}
| {:error, Ecto.Changeset.t()}
def create_measurement(%BeaconMonitor{id: monitor_id}, attrs) when is_map(attrs) do
case lookup_beacon(attrs) do
:not_found ->
{:error, :beacon_not_found}
{:ok, beacon_id} ->
attrs =
attrs
|> normalize_attrs()
|> Map.put("monitor_id", monitor_id)
|> Map.put("beacon_id", beacon_id)
%BeaconMeasurement{}
|> BeaconMeasurement.changeset(attrs)
|> Repo.insert()
end
end
defp lookup_beacon(attrs) do
case Map.get(attrs, "beacon_id") || Map.get(attrs, :beacon_id) do
id when is_binary(id) and byte_size(id) > 0 ->
# Only let monitors report for approved on-the-air beacons. The
# client drops 404s without retry, so an admin un-approving a
# beacon cleanly stops the monitor pointing at it.
query =
from b in Microwaveprop.Beacons.Beacon,
where: b.id == ^id and b.approved == true and b.on_the_air == true,
select: b.id
case safe_one(query) do
nil -> :not_found
uuid -> {:ok, uuid}
end
_ ->
:not_found
end
end
defp safe_one(query) do
Repo.one(query)
rescue
Ecto.Query.CastError -> nil
end
# The wire payload uses string keys (Phoenix JSON params). Make sure
# we don't drop a value because some intermediate handed us atom keys.
defp normalize_attrs(attrs) do
Map.new(attrs, fn
{k, v} when is_atom(k) -> {Atom.to_string(k), v}
{k, v} -> {k, v}
end)
end
@doc """
Returns the most recent measurements for the given beacon, newest first.
"""
@spec list_recent_for_beacon(Ecto.UUID.t(), pos_integer()) :: [BeaconMeasurement.t()]
def list_recent_for_beacon(beacon_id, limit \\ 100) do
Repo.all(
from m in BeaconMeasurement,
where: m.beacon_id == ^beacon_id,
order_by: [desc: m.measured_at],
limit: ^limit
)
end
@doc """
Returns the most recent measurements for the given monitor, newest first.
"""
@spec list_recent_for_monitor(Ecto.UUID.t(), pos_integer()) :: [BeaconMeasurement.t()]
def list_recent_for_monitor(monitor_id, limit \\ 100) do
Repo.all(
from m in BeaconMeasurement,
where: m.monitor_id == ^monitor_id,
order_by: [desc: m.measured_at],
limit: ^limit
)
end
end

View file

@ -0,0 +1,76 @@
defmodule Microwaveprop.BeaconMeasurements.BeaconMeasurement do
@moduledoc """
A single beacon-signal-level measurement uploaded by a propmonitor
client. One row per integration window per (monitor, beacon).
All `*_dbfs` fields are uncalibrated and relative to the SDR ADC
full-scale; `gain_db` is the SDR's reported gain at upload time and
is needed to interpret dBFS trends across gain changes. SNR is
already gain-independent so it is the preferred calibration input.
`signal_*_dbfs` and `snr_*_db` are computed only over FFT frames
whose in-band power exceeded `noise_floor + 3 dB`; `signal_active_fraction`
reports the duty cycle of those frames. When `signal_active_fraction
== 0.0`, no signal was heard and the peak/avg fields sit near the
noise floor (SNR 0 dB).
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Beacons.Beacon
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "beacon_measurements" do
belongs_to :beacon, Beacon
belongs_to :monitor, BeaconMonitor
field :measured_at, :utc_datetime
field :frequency_hz, :integer
field :integration_s, :integer
field :passband_hz, :float
field :gain_db, :float
field :noise_floor_dbfs, :float
field :signal_peak_dbfs, :float
field :signal_avg_dbfs, :float
field :snr_peak_db, :float
field :snr_avg_db, :float
field :signal_active_fraction, :float
field :propmonitor_version, :string
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@cast_fields ~w(beacon_id monitor_id measured_at frequency_hz integration_s
passband_hz gain_db noise_floor_dbfs signal_peak_dbfs
signal_avg_dbfs snr_peak_db snr_avg_db
signal_active_fraction propmonitor_version)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(measurement, attrs) do
measurement
|> cast(attrs, @cast_fields)
|> validate_required(@cast_fields)
|> validate_number(:frequency_hz, greater_than: 0)
|> validate_number(:integration_s, greater_than_or_equal_to: 5, less_than_or_equal_to: 3600)
|> validate_number(:passband_hz, greater_than: 0.0)
|> validate_number(:gain_db, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 80.0)
|> validate_number(:signal_active_fraction,
greater_than_or_equal_to: 0.0,
less_than_or_equal_to: 1.0
)
|> validate_length(:propmonitor_version, max: 32)
|> foreign_key_constraint(:beacon_id)
|> foreign_key_constraint(:monitor_id)
|> unique_constraint([:monitor_id, :beacon_id, :measured_at],
name: :beacon_measurements_monitor_beacon_measured_at_idx
)
end
end

View file

@ -64,6 +64,22 @@ defmodule Microwaveprop.BeaconMonitors do
Repo.get_by(BeaconMonitor, token: token)
end
@doc """
Stamps `last_seen_at` to the given timestamp (defaults to "now") for
the supplied monitor. Used by the measurement ingest endpoint as
effectively a heartbeat every accepted upload nudges the monitor's
last-seen marker forward.
"""
@spec touch_last_seen(BeaconMonitor.t(), DateTime.t() | nil) :: {non_neg_integer(), nil}
def touch_last_seen(%BeaconMonitor{id: id}, at \\ nil) do
at = DateTime.truncate(at || DateTime.utc_now(), :second)
Repo.update_all(
from(m in BeaconMonitor, where: m.id == ^id),
set: [last_seen_at: at]
)
end
@doc """
Returns a blank changeset for rendering the new-monitor form.
"""

View file

@ -0,0 +1,71 @@
defmodule MicrowavepropWeb.Api.MonitorAuth do
@moduledoc """
Bearer-token authentication for propmonitor uploads.
Resolves `Authorization: Bearer <token>` against
`Microwaveprop.BeaconMonitors.get_monitor_by_token/1`, separate from
the user-API-token plug at `MicrowavepropWeb.Api.Auth`. Monitor tokens
are issued via `POST /api/v1/me/beacon-monitors` and identify a
monitor station, not a user session.
On success, assigns:
* `:current_monitor` the `BeaconMonitor` struct
* `:current_api_token` `{:monitor, monitor_id}` so the
rate-limiter buckets the monitor by id rather than IP.
On failure, halts with a 401 `application/problem+json` response.
"""
@behaviour Plug
import Plug.Conn
alias Microwaveprop.BeaconMonitors
alias MicrowavepropWeb.Api.ErrorJSON
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
case extract_bearer(conn) do
{:ok, token} ->
case BeaconMonitors.get_monitor_by_token(token) do
nil -> halt_unauthorized(conn, "Bearer token is invalid, expired, or revoked.")
monitor -> assign_monitor(conn, monitor)
end
{:error, :missing_token} ->
halt_unauthorized(conn, "Missing bearer token in Authorization header.")
{:error, :invalid_authorization_header} ->
halt_unauthorized(conn, "Authorization header must be `Bearer <token>`.")
end
end
defp extract_bearer(conn) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] when byte_size(token) > 0 -> {:ok, token}
["bearer " <> token] when byte_size(token) > 0 -> {:ok, token}
[] -> {:error, :missing_token}
_ -> {:error, :invalid_authorization_header}
end
end
defp assign_monitor(conn, monitor) do
# The rate-limiter buckets on `current_api_token.id`. Map the
# monitor onto that shape so retries from one monitor count
# against a per-monitor bucket, not the shared anon-IP bucket
# (which would punish co-located stations behind one NAT).
conn
|> assign(:current_monitor, monitor)
|> assign(:current_api_token, %{id: {:monitor, monitor.id}})
end
defp halt_unauthorized(conn, detail) do
conn
|> put_resp_header("www-authenticate", ~s(Bearer realm="api"))
|> ErrorJSON.send_problem(401, "unauthorized", detail)
end
end

View file

@ -0,0 +1,73 @@
defmodule MicrowavepropWeb.Api.V1.BeaconMonitorMeasurementController do
@moduledoc """
Ingest endpoint for `propmonitor` clients.
Authenticated by `MicrowavepropWeb.Api.MonitorAuth`: the Bearer token
resolves to a `BeaconMonitor`, and the JSON body carries the
`beacon_id` plus signal/noise/SNR fields for one integration window.
Success returns `204 No Content`. Failures match the propmonitor
status-classification table in `output.md`:
* `401` missing/invalid monitor token (auth plug handles this)
* `404` unknown / unapproved / off-air `beacon_id`
* `422` malformed body
* `409` duplicate `(monitor, beacon, measured_at)` (retried POST)
"""
use Phoenix.Controller, formats: [:json]
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.BeaconMonitors
alias MicrowavepropWeb.Api.ErrorJSON
plug :accepts, ["json"]
@wire_fields ~w(beacon_id frequency_hz measured_at integration_s passband_hz
gain_db noise_floor_dbfs signal_peak_dbfs signal_avg_dbfs
snr_peak_db snr_avg_db signal_active_fraction
propmonitor_version)
def create(conn, params) do
monitor = conn.assigns.current_monitor
attrs = Map.take(params, @wire_fields)
case BeaconMeasurements.create_measurement(monitor, attrs) do
{:ok, _measurement} ->
# Heartbeat: every accepted POST nudges last_seen_at forward.
# propmonitor doesn't send a separate keepalive.
_ = BeaconMonitors.touch_last_seen(monitor)
conn
|> send_resp(204, "")
|> halt()
{:error, :beacon_not_found} ->
ErrorJSON.send_problem(
conn,
404,
"not_found",
"beacon_id is unknown, unapproved, or marked off-the-air."
)
{:error, %Ecto.Changeset{} = changeset} ->
if duplicate?(changeset) do
ErrorJSON.send_problem(
conn,
409,
"conflict",
"A measurement for this monitor, beacon, and measured_at already exists."
)
else
ErrorJSON.send_changeset(conn, changeset)
end
end
end
defp duplicate?(%Ecto.Changeset{errors: errors}) do
Enum.any?(errors, fn
{_field, {_msg, opts}} -> Keyword.get(opts, :constraint) == :unique
_ -> false
end)
end
end

View file

@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Router do
import Phoenix.LiveDashboard.Router
alias MicrowavepropWeb.Api.Auth
alias MicrowavepropWeb.Api.MonitorAuth
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.Api.V1
@ -161,6 +162,16 @@ defmodule MicrowavepropWeb.Router do
plug RateLimiter, anon_limit: 30
end
# propmonitor beacon-measurement uploads. Bearer token resolves to a
# `BeaconMonitor` rather than a user. One POST per integration window
# (default 60 s); allow a comfortable burst headroom for retry storms
# after a 5xx — well above the steady-state 1/min/monitor rate.
pipeline :api_v1_monitor do
plug :accepts, ["json"]
plug MonitorAuth
plug RateLimiter, auth_limit: 60
end
# Health checks — no pipeline, minimal overhead.
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
# Pods that serve a BEAM-alive /live but a failing /health get taken
@ -297,6 +308,14 @@ defmodule MicrowavepropWeb.Router do
post "/beacons", BeaconController, :create
end
# propmonitor uploads. Separate pipeline because the Bearer here is a
# `BeaconMonitor` token, not a user API token.
scope "/api/v1", V1 do
pipe_through :api_v1_monitor
post "/beacon-monitor/measurements", BeaconMonitorMeasurementController, :create
end
scope "/" do
pipe_through [:browser, :require_authenticated_user]

View file

@ -0,0 +1,43 @@
defmodule Microwaveprop.Repo.Migrations.CreateBeaconMeasurements do
use Ecto.Migration
def change do
create table(:beacon_measurements, primary_key: false) do
add :id, :binary_id, primary_key: true
add :beacon_id, references(:beacons, type: :binary_id, on_delete: :delete_all), null: false
add :monitor_id, references(:beacon_monitors, type: :binary_id, on_delete: :delete_all),
null: false
add :measured_at, :utc_datetime, null: false
add :frequency_hz, :bigint, null: false
add :integration_s, :integer, null: false
add :passband_hz, :float, null: false
add :gain_db, :float, null: false
add :noise_floor_dbfs, :float, null: false
add :signal_peak_dbfs, :float, null: false
add :signal_avg_dbfs, :float, null: false
add :snr_peak_db, :float, null: false
add :snr_avg_db, :float, null: false
add :signal_active_fraction, :float, null: false
add :propmonitor_version, :string, null: false
timestamps(type: :utc_datetime)
end
# Time-series query patterns: latest-N for one monitor, latest-N for one
# beacon, and joint monitor+beacon trends.
create index(:beacon_measurements, [:monitor_id, "measured_at DESC"])
create index(:beacon_measurements, [:beacon_id, "measured_at DESC"])
# Idempotency: the client retries on 5xx with the same `measured_at`. A
# given monitor cannot legitimately produce two distinct measurements for
# the same beacon at the same start-of-window timestamp.
create unique_index(:beacon_measurements, [:monitor_id, :beacon_id, :measured_at],
name: :beacon_measurements_monitor_beacon_measured_at_idx
)
end
end

View file

@ -0,0 +1,139 @@
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

View file

@ -0,0 +1,127 @@
defmodule MicrowavepropWeb.Api.V1.BeaconMonitorMeasurementControllerTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.Beacons
alias Microwaveprop.Repo
alias MicrowavepropWeb.Api.RateLimiter
@path "/api/v1/beacon-monitor/measurements"
setup %{conn: conn} do
RateLimiter.reset()
user = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Shack Pi"})
beacon = beacon_fixture(user)
{:ok, beacon} = Beacons.approve_beacon(beacon)
%{conn: conn, user: user, monitor: monitor, beacon: beacon}
end
defp wire_body(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 "POST /api/v1/beacon-monitor/measurements" do
test "401 when no Authorization header is supplied", %{conn: conn, beacon: b} do
conn = post(conn, @path, wire_body(b))
assert response(conn, 401)
end
test "401 when bearer token is unknown", %{conn: conn, beacon: b} do
conn =
conn
|> put_req_header("authorization", "Bearer not-a-real-token")
|> post(@path, wire_body(b))
assert response(conn, 401)
end
test "204 on a valid upload, persists row, and stamps last_seen_at",
%{conn: conn, monitor: m, beacon: b} do
conn =
conn
|> put_req_header("authorization", "Bearer " <> m.token)
|> post(@path, wire_body(b))
assert response(conn, 204) == ""
[row] = BeaconMeasurements.list_recent_for_monitor(m.id, 10)
assert row.beacon_id == b.id
assert row.signal_active_fraction == 0.48
assert %{last_seen_at: %DateTime{}} = Repo.reload!(m)
end
test "404 when beacon_id is unknown", %{conn: conn, monitor: m} do
body = wire_body(%{id: "00000000-0000-0000-0000-000000000000"})
conn =
conn
|> put_req_header("authorization", "Bearer " <> m.token)
|> post(@path, body)
assert json_response(conn, 404)["title"] == "not_found"
end
test "404 when beacon is unapproved", %{conn: conn, monitor: m, user: u} do
pending = beacon_fixture(u, callsign: "K5HOLD")
conn =
conn
|> put_req_header("authorization", "Bearer " <> m.token)
|> post(@path, wire_body(pending))
assert json_response(conn, 404)
end
test "422 when body is missing required fields", %{conn: conn, monitor: m, beacon: b} do
body = Map.delete(wire_body(b), "noise_floor_dbfs")
conn =
conn
|> put_req_header("authorization", "Bearer " <> m.token)
|> post(@path, body)
body_json = json_response(conn, 422)
assert body_json["title"] == "validation_failed"
assert body_json["errors"]["noise_floor_dbfs"]
end
test "409 on duplicate (monitor, beacon, measured_at) retry", %{conn: conn, monitor: m, beacon: b} do
conn1 =
build_conn()
|> put_req_header("authorization", "Bearer " <> m.token)
|> post(@path, wire_body(b))
assert response(conn1, 204)
conn2 =
conn
|> put_req_header("authorization", "Bearer " <> m.token)
|> post(@path, wire_body(b))
assert json_response(conn2, 409)["title"] == "conflict"
end
end
end