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.
130 lines
4 KiB
Elixir
130 lines
4 KiB
Elixir
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
|