diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 511f7caf..375bd481 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/lib/microwaveprop/beacon_measurements.ex b/lib/microwaveprop/beacon_measurements.ex new file mode 100644 index 00000000..20c89247 --- /dev/null +++ b/lib/microwaveprop/beacon_measurements.ex @@ -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 diff --git a/lib/microwaveprop/beacon_measurements/beacon_measurement.ex b/lib/microwaveprop/beacon_measurements/beacon_measurement.ex new file mode 100644 index 00000000..cdc36e72 --- /dev/null +++ b/lib/microwaveprop/beacon_measurements/beacon_measurement.ex @@ -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 diff --git a/lib/microwaveprop/beacon_monitors.ex b/lib/microwaveprop/beacon_monitors.ex index 77e2b29b..974c517c 100644 --- a/lib/microwaveprop/beacon_monitors.ex +++ b/lib/microwaveprop/beacon_monitors.ex @@ -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. """ diff --git a/lib/microwaveprop_web/api/monitor_auth.ex b/lib/microwaveprop_web/api/monitor_auth.ex new file mode 100644 index 00000000..6dda99a6 --- /dev/null +++ b/lib/microwaveprop_web/api/monitor_auth.ex @@ -0,0 +1,71 @@ +defmodule MicrowavepropWeb.Api.MonitorAuth do + @moduledoc """ + Bearer-token authentication for propmonitor uploads. + + Resolves `Authorization: Bearer ` 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 `.") + 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 diff --git a/lib/microwaveprop_web/controllers/api/v1/beacon_monitor_measurement_controller.ex b/lib/microwaveprop_web/controllers/api/v1/beacon_monitor_measurement_controller.ex new file mode 100644 index 00000000..e2630b63 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/beacon_monitor_measurement_controller.ex @@ -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 diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 2ac2f2dc..d57cf602 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -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] diff --git a/priv/repo/migrations/20260513191924_create_beacon_measurements.exs b/priv/repo/migrations/20260513191924_create_beacon_measurements.exs new file mode 100644 index 00000000..e85a8d28 --- /dev/null +++ b/priv/repo/migrations/20260513191924_create_beacon_measurements.exs @@ -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 diff --git a/test/microwaveprop/beacon_measurements_test.exs b/test/microwaveprop/beacon_measurements_test.exs new file mode 100644 index 00000000..c579ac42 --- /dev/null +++ b/test/microwaveprop/beacon_measurements_test.exs @@ -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 diff --git a/test/microwaveprop_web/controllers/api/v1/beacon_monitor_measurement_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/beacon_monitor_measurement_controller_test.exs new file mode 100644 index 00000000..96259c0e --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/beacon_monitor_measurement_controller_test.exs @@ -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