towerops/test/towerops_web/controllers/api/v1/coverages_controller_test.exs
Graham McIntire edb82bcc40 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.
2026-05-06 17:15:57 -05:00

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