feat(coverage): public REST API + KMZ export + email-on-complete
cnHeat-2.0-style automation hooks. Coverage prediction is now a
first-class API resource that Terraform / Ansible / cron can drive.
REST API (under /api/v1, Bearer-token auth scoped to one org)
- GET /coverages list
- POST /coverages create + auto-queue compute
- GET /coverages/:id read (includes status / progress_pct
for polling-based providers)
- PATCH /coverages/:id update editable fields
- DELETE /coverages/:id delete + cleanup
- POST /coverages/:id/recompute requeue, returns 202
- GET /coverages/:id/kmz Google Earth bundle (KML + PNG)
Each response includes the full resource — Terraform read-after-write
reconciles cleanly; status polling hits a stable JSON shape.
Email-on-complete (Towerops.Coverages.Notifier)
- Worker fires deliver_compute_complete on both :ready and :failed
paths. Sends a short text email to every member of the owning
organisation with a link to the show page (or the failure
reason). Mirrors cnHeat 2.0's per-project email alerts.
- Failures are non-fatal: a stuck SMTP relay never blocks the
underlying coverage record from transitioning state.
KMZ export
- Builds a flat KMZ in-memory via :zip.create — doc.kml +
overlay.png, with the LatLonBox set from the coverage's bbox.
XML special chars are escaped. 409 if not yet ready.
Tests (11 new, all green)
- Bearer-token auth happy path + 401 missing
- index / show / create / update / delete + 404 cross-org
- create returns status:'queued' confirming the auto-enqueue
- recompute returns 202 with status:'queued'
Plus docs/terraform-coverages.md showing how to drive the new API
from a third-party restapi provider until the first-party
terraform-provider-towerops gets a coverage resource.
This commit is contained in:
parent
6e4b07026f
commit
edb82bcc40
6 changed files with 680 additions and 0 deletions
104
docs/terraform-coverages.md
Normal file
104
docs/terraform-coverages.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Coverages over the Towerops API (Terraform-friendly)
|
||||
|
||||
Towerops exposes a resourceful REST API for RF coverage predictions
|
||||
that's stable enough to drive from Terraform, Ansible, or any
|
||||
infrastructure-as-code tool. All endpoints live under `/api/v1/` and
|
||||
use Bearer-token auth scoped to a single organization.
|
||||
|
||||
## Authentication
|
||||
|
||||
Mint a token from **Settings → API tokens**. Send it as:
|
||||
|
||||
```
|
||||
Authorization: Bearer towerops_<token>
|
||||
```
|
||||
|
||||
The token is org-scoped — every endpoint reads/writes inside that
|
||||
org only.
|
||||
|
||||
## Resources
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---------|---------------------------------------|---------|
|
||||
| GET | `/api/v1/coverages` | List all coverages in the org. |
|
||||
| POST | `/api/v1/coverages` | Create a coverage. The compute pipeline is queued automatically. |
|
||||
| GET | `/api/v1/coverages/:id` | Read a single coverage including current `status` / `progress_pct`. |
|
||||
| PATCH | `/api/v1/coverages/:id` | Update editable fields (does not auto-recompute). |
|
||||
| DELETE | `/api/v1/coverages/:id` | Delete a coverage and its on-disk rasters. |
|
||||
| POST | `/api/v1/coverages/:id/recompute` | Re-run the compute pipeline. Returns `202` with `status: "queued"`. |
|
||||
| GET | `/api/v1/coverages/:id/kmz` | Download a KMZ overlay for Google Earth (returns `409` until `status: "ready"`). |
|
||||
|
||||
## Create payload
|
||||
|
||||
```json
|
||||
{
|
||||
"coverage": {
|
||||
"name": "North sector 5 GHz",
|
||||
"site_id": "1f4...uuid",
|
||||
"antenna_slug": "rf-elements-twistport-symmetrical-horn-30",
|
||||
"frequency_ghz": 5.8,
|
||||
"tx_power_dbm": 22.0,
|
||||
"height_agl_ft": 100,
|
||||
"azimuth_deg": 0,
|
||||
"downtilt_deg": 0,
|
||||
"radius_mi": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Imperial inputs are accepted via the virtual fields shown above; the
|
||||
schema converts them to SI before validation.
|
||||
|
||||
## Polling for `:ready`
|
||||
|
||||
Compute is asynchronous. After `POST` you'll receive `status:
|
||||
"queued"`; the worker transitions through `"computing"` to
|
||||
`"ready"` (or `"failed"`). Terraform providers can poll with
|
||||
`GET /api/v1/coverages/:id` until `status == "ready"` before
|
||||
declaring the resource healthy.
|
||||
|
||||
## Terraform example
|
||||
|
||||
A Terraform provider for Towerops isn't published yet, but a
|
||||
hand-rolled `restapi` provider works today:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
required_providers {
|
||||
restapi = { source = "Mastercard/restapi", version = "~> 1.18" }
|
||||
}
|
||||
}
|
||||
|
||||
provider "restapi" {
|
||||
uri = "https://app.towerops.com"
|
||||
write_returns_object = true
|
||||
headers = {
|
||||
Authorization = "Bearer ${var.towerops_token}"
|
||||
Content-Type = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
resource "restapi_object" "north_sector" {
|
||||
path = "/api/v1/coverages"
|
||||
id_attribute = "id"
|
||||
|
||||
data = jsonencode({
|
||||
coverage = {
|
||||
name = "North sector 5 GHz"
|
||||
site_id = var.site_id
|
||||
antenna_slug = "rf-elements-twistport-symmetrical-horn-30"
|
||||
frequency_ghz = 5.8
|
||||
tx_power_dbm = 22.0
|
||||
height_agl_ft = 100
|
||||
azimuth_deg = 0
|
||||
downtilt_deg = 0
|
||||
radius_mi = 4
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`terraform apply` creates the coverage; `terraform destroy` deletes
|
||||
it. Updates flow through `PATCH`. Use `terraform_data` + a
|
||||
`provisioner` if you need to wait on `status = "ready"` before
|
||||
proceeding.
|
||||
114
lib/towerops/coverages/notifier.ex
Normal file
114
lib/towerops/coverages/notifier.ex
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
defmodule Towerops.Coverages.Notifier do
|
||||
@moduledoc """
|
||||
Email notifications for coverage compute lifecycle events.
|
||||
|
||||
When the worker finalizes a coverage (`:ready` or `:failed`) it
|
||||
asks this module to fan out a short email to every member of the
|
||||
owning organization. Mirrors the cnHeat 2.0 behaviour where each
|
||||
user subscribed to a project receives an email when a prediction
|
||||
finishes.
|
||||
|
||||
Failures here are non-fatal — a stuck SMTP relay must never block
|
||||
the underlying coverage record from transitioning state. Errors
|
||||
log and continue.
|
||||
"""
|
||||
|
||||
use Gettext, backend: ToweropsWeb.Gettext
|
||||
|
||||
import Swoosh.Email
|
||||
import ToweropsWeb.GettextHelpers
|
||||
|
||||
alias Towerops.Coverages.Coverage
|
||||
alias Towerops.Mailer
|
||||
alias Towerops.Organizations
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends a compute-complete email to every member of the coverage's
|
||||
organization. `status` is `:ready` or `:failed`.
|
||||
"""
|
||||
@spec deliver_compute_complete(Coverage.t(), :ready | :failed) :: :ok
|
||||
def deliver_compute_complete(%Coverage{} = coverage, status) when status in [:ready, :failed] do
|
||||
members = Organizations.list_organization_members(coverage.organization_id)
|
||||
|
||||
Enum.each(members, fn member ->
|
||||
_ = deliver_to(member, coverage, status)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp deliver_to(member, coverage, status) do
|
||||
user = member.user
|
||||
|
||||
if is_nil(user) or is_nil(user.email) do
|
||||
:ok
|
||||
else
|
||||
from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"})
|
||||
subject = subject_for(status, coverage)
|
||||
body = body_for(status, user, coverage)
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(user.email)
|
||||
|> from(from_address)
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _meta} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Coverages.Notifier: failed to send #{status} email " <>
|
||||
"to #{user.email} for coverage #{coverage.id}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp subject_for(:ready, coverage), do: t_email("Coverage prediction ready: %{name}", name: coverage.name)
|
||||
|
||||
defp subject_for(:failed, coverage), do: t_email("Coverage prediction failed: %{name}", name: coverage.name)
|
||||
|
||||
defp body_for(:ready, user, coverage) do
|
||||
t_email(
|
||||
"""
|
||||
Hi %{name},
|
||||
|
||||
Coverage "%{coverage}" finished computing. View it here:
|
||||
|
||||
%{url}
|
||||
|
||||
— Towerops
|
||||
""",
|
||||
name: user.first_name || user.email,
|
||||
coverage: coverage.name,
|
||||
url: ToweropsWeb.Endpoint.url() <> "/coverage/#{coverage.id}"
|
||||
)
|
||||
end
|
||||
|
||||
defp body_for(:failed, user, coverage) do
|
||||
t_email(
|
||||
"""
|
||||
Hi %{name},
|
||||
|
||||
Coverage "%{coverage}" failed to compute.
|
||||
|
||||
Reason: %{reason}
|
||||
|
||||
View / retry here: %{url}
|
||||
|
||||
— Towerops
|
||||
""",
|
||||
name: user.first_name || user.email,
|
||||
coverage: coverage.name,
|
||||
reason: coverage.error_message || "(no message)",
|
||||
url: ToweropsWeb.Endpoint.url() <> "/coverage/#{coverage.id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -33,6 +33,7 @@ defmodule Towerops.Workers.CoverageWorker do
|
|||
alias Towerops.Coverages.Antenna
|
||||
alias Towerops.Coverages.Buildings
|
||||
alias Towerops.Coverages.Coverage
|
||||
alias Towerops.Coverages.Notifier
|
||||
alias Towerops.Coverages.Profile
|
||||
alias Towerops.Coverages.Propagation
|
||||
alias Towerops.Coverages.Raster
|
||||
|
|
@ -392,6 +393,7 @@ defmodule Towerops.Workers.CoverageWorker do
|
|||
})
|
||||
|
||||
Coverages.broadcast(updated, {:coverage_status, :ready, 100})
|
||||
_ = Notifier.deliver_compute_complete(updated, :ready)
|
||||
:ok
|
||||
end
|
||||
|
||||
|
|
@ -412,6 +414,7 @@ defmodule Towerops.Workers.CoverageWorker do
|
|||
})
|
||||
|
||||
Coverages.broadcast(updated, {:coverage_status, :failed, message})
|
||||
_ = Notifier.deliver_compute_complete(updated, :failed)
|
||||
|
||||
# Don't let Oban retry — the failure is on the coverage record.
|
||||
:ok
|
||||
|
|
|
|||
324
lib/towerops_web/controllers/api/v1/coverages_controller.ex
Normal file
324
lib/towerops_web/controllers/api/v1/coverages_controller.ex
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
defmodule ToweropsWeb.Api.V1.CoveragesController do
|
||||
@moduledoc """
|
||||
REST API for RF coverage predictions.
|
||||
|
||||
All endpoints are scoped to the organization the API token belongs
|
||||
to. Designed for Terraform / IaC consumers as well as ad-hoc
|
||||
scripts: stable UUID resource IDs, predictable JSON shape, status
|
||||
field present on every response so polling-based providers can
|
||||
wait for a `:ready` transition before declaring a resource healthy.
|
||||
|
||||
Authentication: `Authorization: Bearer towerops_<token>`. Mint a
|
||||
token from /settings → API tokens.
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
||||
|
||||
alias Towerops.Coverages
|
||||
|
||||
@doc """
|
||||
GET /api/v1/coverages
|
||||
|
||||
Lists every coverage in the authenticated organization.
|
||||
|
||||
Response:
|
||||
{
|
||||
"coverages": [
|
||||
{ "id": "uuid", "name": "...", ... }
|
||||
]
|
||||
}
|
||||
"""
|
||||
def index(conn, _params) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
coverages = Coverages.list_for_organization(org_id)
|
||||
json(conn, %{coverages: Enum.map(coverages, &format/1)})
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/v1/coverages
|
||||
|
||||
Creates a coverage and immediately enqueues compute. Body:
|
||||
{
|
||||
"coverage": {
|
||||
"name": "North sector 5 GHz",
|
||||
"site_id": "uuid",
|
||||
"antenna_slug": "rf-elements-tp-sh-30",
|
||||
"frequency_ghz": 5.8,
|
||||
"tx_power_dbm": 22.0,
|
||||
"height_agl_ft": 100,
|
||||
"azimuth_deg": 0,
|
||||
"downtilt_deg": 0,
|
||||
"radius_mi": 4
|
||||
}
|
||||
}
|
||||
"""
|
||||
def create(conn, %{"coverage" => attrs}) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
|
||||
case Coverages.create_coverage(org_id, attrs) do
|
||||
{:ok, coverage} ->
|
||||
case Coverages.queue_compute(coverage) do
|
||||
{:ok, queued} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format(queued))
|
||||
|
||||
{:error, _} ->
|
||||
# Coverage exists but compute couldn't be queued; expose
|
||||
# the row so Terraform's read-after-write reconciles.
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format(coverage))
|
||||
end
|
||||
|
||||
{:error, %Ecto.Changeset{} = cs} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: translate_errors(cs)})
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _), do: bad_request(conn, "Missing 'coverage' parameter")
|
||||
|
||||
@doc """
|
||||
GET /api/v1/coverages/:id
|
||||
|
||||
Returns a single coverage. 404 if it doesn't belong to the
|
||||
authenticated org.
|
||||
"""
|
||||
def show(conn, %{"id" => id}) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
|
||||
case Coverages.get_coverage(org_id, id) do
|
||||
nil -> not_found(conn)
|
||||
coverage -> json(conn, format(coverage))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
PATCH /api/v1/coverages/:id
|
||||
|
||||
Updates editable fields. Does NOT auto-recompute — callers should
|
||||
POST /api/v1/coverages/:id/recompute when they want fresh outputs.
|
||||
"""
|
||||
def update(conn, %{"id" => id, "coverage" => attrs}) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
|
||||
case Coverages.get_coverage(org_id, id) do
|
||||
nil ->
|
||||
not_found(conn)
|
||||
|
||||
coverage ->
|
||||
case Coverages.update_coverage(coverage, attrs) do
|
||||
{:ok, updated} ->
|
||||
json(conn, format(updated))
|
||||
|
||||
{:error, %Ecto.Changeset{} = cs} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: translate_errors(cs)})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, _), do: bad_request(conn, "Missing 'coverage' parameter")
|
||||
|
||||
@doc """
|
||||
DELETE /api/v1/coverages/:id
|
||||
"""
|
||||
def delete(conn, %{"id" => id}) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
|
||||
case Coverages.get_coverage(org_id, id) do
|
||||
nil ->
|
||||
not_found(conn)
|
||||
|
||||
coverage ->
|
||||
case Coverages.delete_coverage(coverage) do
|
||||
{:ok, _} -> send_resp(conn, :no_content, "")
|
||||
{:error, _} -> bad_request(conn, "Could not delete coverage")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/v1/coverages/:id/recompute
|
||||
|
||||
Re-runs the compute pipeline for an already-existing coverage. Use
|
||||
this from Terraform after `terraform apply` if you want a fresh
|
||||
heatmap without changing parameters.
|
||||
"""
|
||||
def recompute(conn, %{"id" => id}) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
|
||||
case Coverages.get_coverage(org_id, id) do
|
||||
nil ->
|
||||
not_found(conn)
|
||||
|
||||
coverage ->
|
||||
case Coverages.queue_compute(coverage) do
|
||||
{:ok, queued} ->
|
||||
conn
|
||||
|> put_status(:accepted)
|
||||
|> json(format(queued))
|
||||
|
||||
{:error, :compute_in_progress} ->
|
||||
conn
|
||||
|> put_status(:conflict)
|
||||
|> json(%{error: "Coverage is already computing"})
|
||||
|
||||
{:error, _} ->
|
||||
bad_request(conn, "Could not queue compute")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/coverages/:id/kmz
|
||||
|
||||
Returns the coverage heatmap as a KMZ (KML + PNG) bundle ready to
|
||||
drop into Google Earth or any compatible viewer.
|
||||
"""
|
||||
def kmz(conn, %{"id" => id}) do
|
||||
org_id = conn.assigns.current_organization_id
|
||||
|
||||
with %{} = coverage <- Coverages.get_coverage(org_id, id),
|
||||
{:ok, kmz_bytes, filename} <- build_kmz(coverage) do
|
||||
conn
|
||||
|> put_resp_content_type("application/vnd.google-earth.kmz")
|
||||
|> put_resp_header("content-disposition", ~s|attachment; filename="#{filename}"|)
|
||||
|> send_resp(200, kmz_bytes)
|
||||
else
|
||||
nil ->
|
||||
not_found(conn)
|
||||
|
||||
{:error, :not_ready} ->
|
||||
conn
|
||||
|> put_status(:conflict)
|
||||
|> json(%{error: "Coverage is not ready yet"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> json(%{error: "Failed to build KMZ: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
defp build_kmz(%{status: status}) when status != "ready", do: {:error, :not_ready}
|
||||
|
||||
defp build_kmz(%{png_path: nil}), do: {:error, :not_ready}
|
||||
|
||||
defp build_kmz(coverage) do
|
||||
static = Application.app_dir(:towerops, "priv/static")
|
||||
abs_png = Path.join(static, Path.relative_to(coverage.png_path, "/"))
|
||||
|
||||
with true <- File.exists?(abs_png),
|
||||
{:ok, png_bytes} <- File.read(abs_png) do
|
||||
kml = build_kml(coverage)
|
||||
filename = "coverage-#{coverage.id}.kmz"
|
||||
|
||||
# Build a flat KMZ — a ZIP containing doc.kml + the PNG.
|
||||
~c"in_memory.kmz"
|
||||
|> :zip.create(
|
||||
[
|
||||
{~c"doc.kml", kml},
|
||||
{~c"overlay.png", png_bytes}
|
||||
],
|
||||
[:memory]
|
||||
)
|
||||
|> case do
|
||||
{:ok, {_, bin}} -> {:ok, bin, filename}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
else
|
||||
false -> {:error, :png_missing}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_kml(c) do
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>#{escape(c.name)}</name>
|
||||
<description>Towerops RF coverage prediction</description>
|
||||
<GroundOverlay>
|
||||
<name>#{escape(c.name)} (RSSI)</name>
|
||||
<Icon><href>overlay.png</href></Icon>
|
||||
<LatLonBox>
|
||||
<north>#{c.bbox_max_lat}</north>
|
||||
<south>#{c.bbox_min_lat}</south>
|
||||
<east>#{c.bbox_max_lon}</east>
|
||||
<west>#{c.bbox_min_lon}</west>
|
||||
</LatLonBox>
|
||||
</GroundOverlay>
|
||||
</Document>
|
||||
</kml>
|
||||
"""
|
||||
end
|
||||
|
||||
defp escape(nil), do: ""
|
||||
|
||||
defp escape(s) when is_binary(s) do
|
||||
s
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
end
|
||||
|
||||
defp format(c) do
|
||||
%{
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
organization_id: c.organization_id,
|
||||
site_id: c.site_id,
|
||||
device_id: c.device_id,
|
||||
antenna_slug: c.antenna_slug,
|
||||
frequency_mhz: c.frequency_mhz,
|
||||
tx_power_dbm: c.tx_power_dbm,
|
||||
cable_loss_db: c.cable_loss_db,
|
||||
sm_gain_dbi: c.sm_gain_dbi,
|
||||
height_agl_m: c.height_agl_m,
|
||||
height_above_rooftop_m: c.height_above_rooftop_m,
|
||||
azimuth_deg: c.azimuth_deg,
|
||||
downtilt_deg: c.downtilt_deg,
|
||||
radius_m: c.radius_m,
|
||||
cell_size_m: c.cell_size_m,
|
||||
receiver_height_m: c.receiver_height_m,
|
||||
rx_threshold_dbm: c.rx_threshold_dbm,
|
||||
foliage_tuning: c.foliage_tuning,
|
||||
latitude_override: c.latitude_override,
|
||||
longitude_override: c.longitude_override,
|
||||
status: c.status,
|
||||
progress_pct: c.progress_pct,
|
||||
error_message: c.error_message,
|
||||
computed_at: c.computed_at,
|
||||
png_path: c.png_path,
|
||||
raster_path: c.raster_path,
|
||||
height_tiers: c.height_tiers,
|
||||
nlos_tiers: c.nlos_tiers,
|
||||
bbox: %{
|
||||
min_lat: c.bbox_min_lat,
|
||||
max_lat: c.bbox_max_lat,
|
||||
min_lon: c.bbox_min_lon,
|
||||
max_lon: c.bbox_max_lon
|
||||
},
|
||||
inserted_at: c.inserted_at,
|
||||
updated_at: c.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
defp not_found(conn) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Coverage not found"})
|
||||
end
|
||||
|
||||
defp bad_request(conn, msg) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: msg})
|
||||
end
|
||||
end
|
||||
|
|
@ -191,6 +191,11 @@ defmodule ToweropsWeb.Router do
|
|||
resources "/integrations", IntegrationsController, except: [:new, :edit]
|
||||
post "/integrations/:id/test", IntegrationsController, :test_connection
|
||||
|
||||
# RF coverage prediction (Terraform-friendly resourceful CRUD).
|
||||
resources "/coverages", CoveragesController, except: [:new, :edit]
|
||||
post "/coverages/:id/recompute", CoveragesController, :recompute
|
||||
get "/coverages/:id/kmz", CoveragesController, :kmz
|
||||
|
||||
get "/devices/:device_id/checks", CheckResultsController, :checks
|
||||
get "/devices/:device_id/metrics", CheckResultsController, :metrics
|
||||
get "/devices/:device_id/interfaces", CheckResultsController, :interfaces
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
defmodule ToweropsWeb.Api.V1.CoveragesControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: false
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.CoveragesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
|
||||
setup %{conn: conn} do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74})
|
||||
|
||||
{:ok, {_token, raw}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: org.id,
|
||||
user_id: user.id,
|
||||
name: "test"
|
||||
})
|
||||
|
||||
conn = put_req_header(conn, "authorization", "Bearer #{raw}")
|
||||
%{conn: conn, org: org, user: user, site: site}
|
||||
end
|
||||
|
||||
describe "GET /api/v1/coverages" do
|
||||
test "returns coverages for the authenticated organization", %{conn: conn, org: org, site: site} do
|
||||
cov = coverage_fixture(org.id, site.id, %{name: "Sector A"})
|
||||
|
||||
resp = conn |> get(~p"/api/v1/coverages") |> json_response(200)
|
||||
|
||||
assert [%{"id" => id, "name" => "Sector A"}] = resp["coverages"]
|
||||
assert id == cov.id
|
||||
end
|
||||
|
||||
test "returns empty list when org has no coverages", %{conn: conn} do
|
||||
assert %{"coverages" => []} = conn |> get(~p"/api/v1/coverages") |> json_response(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/v1/coverages" do
|
||||
test "creates and queues a coverage", %{conn: conn, site: site} do
|
||||
attrs = %{
|
||||
"name" => "API-created",
|
||||
"site_id" => site.id,
|
||||
"antenna_slug" => "simulate-isotropic-omni-0",
|
||||
"frequency_ghz" => 5.8,
|
||||
"tx_power_dbm" => 20.0,
|
||||
"height_agl_ft" => 80,
|
||||
"azimuth_deg" => 0,
|
||||
"downtilt_deg" => 0,
|
||||
"radius_mi" => 3
|
||||
}
|
||||
|
||||
resp = conn |> post(~p"/api/v1/coverages", coverage: attrs) |> json_response(201)
|
||||
|
||||
assert resp["name"] == "API-created"
|
||||
assert resp["status"] == "queued"
|
||||
assert resp["site_id"] == site.id
|
||||
assert is_binary(resp["id"])
|
||||
end
|
||||
|
||||
test "returns 422 with field errors on bad input", %{conn: conn} do
|
||||
resp =
|
||||
conn
|
||||
|> post(~p"/api/v1/coverages", coverage: %{"name" => ""})
|
||||
|> json_response(422)
|
||||
|
||||
assert resp["errors"]
|
||||
end
|
||||
|
||||
test "returns 400 when 'coverage' wrapper is missing", %{conn: conn} do
|
||||
resp = conn |> post(~p"/api/v1/coverages", %{}) |> json_response(400)
|
||||
assert resp["error"] =~ "Missing"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/v1/coverages/:id" do
|
||||
test "returns a single coverage", %{conn: conn, org: org, site: site} do
|
||||
cov = coverage_fixture(org.id, site.id)
|
||||
resp = conn |> get(~p"/api/v1/coverages/#{cov.id}") |> json_response(200)
|
||||
assert resp["id"] == cov.id
|
||||
end
|
||||
|
||||
test "returns 404 for cross-org coverage", %{conn: conn} do
|
||||
assert conn
|
||||
|> get(~p"/api/v1/coverages/#{Ecto.UUID.generate()}")
|
||||
|> json_response(404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /api/v1/coverages/:id" do
|
||||
test "updates editable fields", %{conn: conn, org: org, site: site} do
|
||||
cov = coverage_fixture(org.id, site.id, %{name: "before"})
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> patch(~p"/api/v1/coverages/#{cov.id}", coverage: %{"name" => "after"})
|
||||
|> json_response(200)
|
||||
|
||||
assert resp["name"] == "after"
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /api/v1/coverages/:id" do
|
||||
test "deletes the coverage", %{conn: conn, org: org, site: site} do
|
||||
cov = coverage_fixture(org.id, site.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/coverages/#{cov.id}")
|
||||
assert response(conn, 204)
|
||||
|
||||
assert nil == Towerops.Coverages.get_coverage(org.id, cov.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/v1/coverages/:id/recompute" do
|
||||
test "queues a recompute and returns 202", %{conn: conn, org: org, site: site} do
|
||||
cov = coverage_fixture(org.id, site.id, %{status: "ready"})
|
||||
resp = conn |> post(~p"/api/v1/coverages/#{cov.id}/recompute") |> json_response(202)
|
||||
assert resp["status"] == "queued"
|
||||
end
|
||||
end
|
||||
|
||||
describe "auth" do
|
||||
test "rejects requests without a Bearer token" do
|
||||
conn = get(build_conn(), ~p"/api/v1/coverages")
|
||||
assert response(conn, 401)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue