47 KiB
Preseem Integration Stage 1: Foundation Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build the generic integrations system (schema, context, encrypted credentials, UI) and the Preseem API client with test-connection support.
Architecture: New Towerops.Integrations context with per-organization integration records. Credentials encrypted at rest via Cloak (new Encrypted.Map type for JSONB). Preseem API client uses :req. Settings UI is a new LiveView page at /orgs/:org_slug/settings/integrations.
Tech Stack: Phoenix 1.8, Ecto, Cloak AES-256-GCM, Oban, Req, LiveView
Working directory: /Users/graham/dev/towerops/towerops-web/.worktrees/feature/preseem-integration
Pre-existing test failures: 19 failures exist on main (Monitoring, SiteLive, AgentChannel). NIF tests are skipped in this worktree. Do not attempt to fix these.
Task 1: Create Encrypted.Map Ecto Type
Files:
- Create:
lib/towerops/ecto_types/encrypted_map.ex - Test:
test/towerops/ecto_types/encrypted_map_test.exs
Step 1: Write the failing test
# test/towerops/ecto_types/encrypted_map_test.exs
defmodule Towerops.Encrypted.MapTest do
use Towerops.DataCase, async: true
alias Towerops.Encrypted.Map, as: EncryptedMap
describe "cast/1" do
test "casts a valid map" do
assert {:ok, %{"key" => "value"}} = EncryptedMap.cast(%{"key" => "value"})
end
test "casts atom-keyed maps" do
assert {:ok, %{key: "value"}} = EncryptedMap.cast(%{key: "value"})
end
test "rejects non-map values" do
assert :error = EncryptedMap.cast("not a map")
assert :error = EncryptedMap.cast(123)
end
test "casts nil" do
assert {:ok, nil} = EncryptedMap.cast(nil)
end
end
describe "dump/1 and load/1 round-trip" do
test "encrypts and decrypts a map" do
original = %{"api_key" => "secret123", "base_url" => "https://api.preseem.com"}
assert {:ok, encrypted} = EncryptedMap.dump(original)
assert is_binary(encrypted)
# Encrypted value should not be the raw JSON
refute encrypted == Jason.encode!(original)
assert {:ok, decrypted} = EncryptedMap.load(encrypted)
assert decrypted == original
end
test "handles nil values" do
assert {:ok, nil} = EncryptedMap.dump(nil)
assert {:ok, nil} = EncryptedMap.load(nil)
end
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops/ecto_types/encrypted_map_test.exs
Expected: Compilation error — module Towerops.Encrypted.Map not found
Step 3: Write minimal implementation
# lib/towerops/ecto_types/encrypted_map.ex
defmodule Towerops.Encrypted.Map do
@moduledoc """
Encrypted map field using Cloak vault.
Used for storing sensitive configuration like API credentials as encrypted JSON.
Data is encrypted at rest using AES-256-GCM.
## Usage
field :credentials, Towerops.Encrypted.Map
## Database Schema
The database column must be `:binary`:
add :credentials, :binary
Note: Atom keys become string keys after decryption.
"""
use Cloak.Ecto.Map, vault: Towerops.Vault
end
Step 4: Run test to verify it passes
Run: mix test test/towerops/ecto_types/encrypted_map_test.exs
Expected: All tests pass
Step 5: Commit
git add lib/towerops/ecto_types/encrypted_map.ex test/towerops/ecto_types/encrypted_map_test.exs
git commit -m "add encrypted map ecto type for integration credentials"
Task 2: Create Integrations Schema and Migration
Files:
- Create:
lib/towerops/integrations/integration.ex - Create: migration via
mix ecto.gen.migration create_integrations - Test:
test/towerops/integrations/integration_test.exs
Step 1: Generate migration
Run: mix ecto.gen.migration create_integrations
Step 2: Write the migration
defmodule Towerops.Repo.Migrations.CreateIntegrations do
use Ecto.Migration
def change do
create table(:integrations, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :provider, :string, null: false
add :enabled, :boolean, default: false, null: false
add :credentials, :binary
add :sync_interval_minutes, :integer, default: 10
add :last_synced_at, :utc_datetime
add :last_sync_status, :string, default: "never"
timestamps(type: :utc_datetime)
end
create unique_index(:integrations, [:organization_id, :provider])
create index(:integrations, [:organization_id])
end
end
Step 3: Run migration
Run: mix ecto.migrate
Step 4: Write failing schema test
# test/towerops/integrations/integration_test.exs
defmodule Towerops.Integrations.IntegrationTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations.Integration
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{organization: organization}
end
describe "changeset/2" do
test "valid changeset with required fields", %{organization: org} do
attrs = %{
organization_id: org.id,
provider: "preseem",
enabled: true,
credentials: %{"api_key" => "test-key-123"}
}
changeset = Integration.changeset(%Integration{}, attrs)
assert changeset.valid?
end
test "requires organization_id" do
attrs = %{provider: "preseem"}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "requires provider" do
attrs = %{organization_id: Ecto.UUID.generate()}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider
end
test "validates provider is a known value" do
attrs = %{
organization_id: Ecto.UUID.generate(),
provider: "unknown_provider"
}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).provider
end
test "validates sync_interval_minutes is positive" do
attrs = %{
organization_id: Ecto.UUID.generate(),
provider: "preseem",
sync_interval_minutes: 0
}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "must be greater than 0" in errors_on(changeset).sync_interval_minutes
end
test "validates last_sync_status is a known value" do
attrs = %{
organization_id: Ecto.UUID.generate(),
provider: "preseem",
last_sync_status: "bogus"
}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).last_sync_status
end
test "enforces unique constraint on org + provider", %{organization: org} do
attrs = %{organization_id: org.id, provider: "preseem"}
{:ok, _} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()
assert {:error, changeset} =
%Integration{} |> Integration.changeset(attrs) |> Repo.insert()
assert "has already been taken" in errors_on(changeset).provider
end
end
describe "credentials encryption" do
test "credentials are encrypted at rest", %{organization: org} do
attrs = %{
organization_id: org.id,
provider: "preseem",
credentials: %{"api_key" => "super-secret-key"}
}
{:ok, integration} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()
# Reload from database
reloaded = Repo.get!(Integration, integration.id)
assert reloaded.credentials == %{"api_key" => "super-secret-key"}
# Verify raw binary in DB is NOT the plain JSON
raw =
Repo.one(
from i in "integrations",
where: i.id == type(^integration.id, :binary_id),
select: i.credentials
)
refute raw == Jason.encode!(attrs.credentials)
end
end
end
Step 5: Run test to verify it fails
Run: mix test test/towerops/integrations/integration_test.exs
Expected: Compilation error — module Towerops.Integrations.Integration not found
Step 6: Write the schema
# lib/towerops/integrations/integration.ex
defmodule Towerops.Integrations.Integration do
@moduledoc """
Schema for per-organization third-party integrations.
Each organization can have one integration per provider (e.g., one Preseem
integration). Credentials are encrypted at rest using Cloak AES-256-GCM.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Encrypted
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_providers ~w(preseem)
@valid_sync_statuses ~w(never success partial failed)
schema "integrations" do
field :provider, :string
field :enabled, :boolean, default: false
field :credentials, Encrypted.Map
field :sync_interval_minutes, :integer, default: 10
field :last_synced_at, :utc_datetime
field :last_sync_status, :string, default: "never"
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
def changeset(integration, attrs) do
integration
|> cast(attrs, [
:organization_id,
:provider,
:enabled,
:credentials,
:sync_interval_minutes,
:last_synced_at,
:last_sync_status
])
|> validate_required([:organization_id, :provider])
|> validate_inclusion(:provider, @valid_providers)
|> validate_inclusion(:last_sync_status, @valid_sync_statuses)
|> validate_number(:sync_interval_minutes, greater_than: 0)
|> unique_constraint([:organization_id, :provider],
error_key: :provider,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
end
end
Step 7: Run test to verify it passes
Run: mix test test/towerops/integrations/integration_test.exs
Expected: All tests pass
Step 8: Commit
git add lib/towerops/integrations/integration.ex test/towerops/integrations/integration_test.exs priv/repo/migrations/*_create_integrations.exs
git commit -m "add integrations schema with encrypted credentials"
Task 3: Create Integrations Context
Files:
- Create:
lib/towerops/integrations.ex - Test:
test/towerops/integrations_test.exs
Step 1: Write failing context test
# test/towerops/integrations_test.exs
defmodule Towerops.IntegrationsTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations
alias Towerops.Integrations.Integration
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{organization: organization}
end
describe "list_integrations/1" do
test "returns all integrations for an organization", %{organization: org} do
{:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"})
integrations = Integrations.list_integrations(org.id)
assert length(integrations) == 1
end
test "does not return integrations from other orgs", %{organization: org} do
user2 = user_fixture()
org2 = organization_fixture(user2.id)
{:ok, _} = Integrations.create_integration(org2.id, %{provider: "preseem"})
assert Integrations.list_integrations(org.id) == []
end
end
describe "get_integration/2" do
test "returns integration by org and provider", %{organization: org} do
{:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"})
assert {:ok, fetched} = Integrations.get_integration(org.id, "preseem")
assert fetched.id == created.id
end
test "returns error when not found", %{organization: org} do
assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem")
end
end
describe "get_integration!/2" do
test "returns integration by org and provider", %{organization: org} do
{:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"})
fetched = Integrations.get_integration!(org.id, "preseem")
assert fetched.id == created.id
end
test "raises when not found", %{organization: org} do
assert_raise Ecto.NoResultsError, fn ->
Integrations.get_integration!(org.id, "preseem")
end
end
end
describe "create_integration/2" do
test "creates with valid attrs", %{organization: org} do
attrs = %{
provider: "preseem",
credentials: %{"api_key" => "test-123"},
enabled: true
}
assert {:ok, %Integration{} = integration} =
Integrations.create_integration(org.id, attrs)
assert integration.provider == "preseem"
assert integration.credentials == %{"api_key" => "test-123"}
assert integration.enabled == true
assert integration.organization_id == org.id
end
test "rejects duplicate provider per org", %{organization: org} do
{:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"})
assert {:error, changeset} =
Integrations.create_integration(org.id, %{provider: "preseem"})
assert "has already been taken" in errors_on(changeset).provider
end
end
describe "update_integration/2" do
test "updates credentials", %{organization: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "preseem",
credentials: %{"api_key" => "old-key"}
})
assert {:ok, updated} =
Integrations.update_integration(integration, %{
credentials: %{"api_key" => "new-key"}
})
assert updated.credentials == %{"api_key" => "new-key"}
end
test "updates enabled flag", %{organization: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{provider: "preseem", enabled: false})
assert {:ok, updated} = Integrations.update_integration(integration, %{enabled: true})
assert updated.enabled == true
end
end
describe "update_sync_status/3" do
test "updates last_synced_at and last_sync_status", %{organization: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{provider: "preseem"})
assert integration.last_sync_status == "never"
assert integration.last_synced_at == nil
assert {:ok, updated} = Integrations.update_sync_status(integration, "success")
assert updated.last_sync_status == "success"
assert updated.last_synced_at != nil
end
end
describe "delete_integration/1" do
test "deletes the integration", %{organization: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{provider: "preseem"})
assert {:ok, _} = Integrations.delete_integration(integration)
assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem")
end
end
describe "change_integration/2" do
test "returns a changeset", %{organization: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{provider: "preseem"})
assert %Ecto.Changeset{} = Integrations.change_integration(integration)
end
end
describe "list_enabled_integrations/1" do
test "returns only enabled integrations for a provider", %{organization: org} do
{:ok, _} =
Integrations.create_integration(org.id, %{provider: "preseem", enabled: true})
user2 = user_fixture()
org2 = organization_fixture(user2.id)
{:ok, _} =
Integrations.create_integration(org2.id, %{provider: "preseem", enabled: false})
enabled = Integrations.list_enabled_integrations("preseem")
assert length(enabled) == 1
assert hd(enabled).organization_id == org.id
end
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops/integrations_test.exs
Expected: Compilation error — module Towerops.Integrations not found
Step 3: Write the context
# lib/towerops/integrations.ex
defmodule Towerops.Integrations do
@moduledoc """
Context for managing third-party integrations per organization.
"""
import Ecto.Query
alias Towerops.Integrations.Integration
alias Towerops.Repo
def list_integrations(organization_id) do
Integration
|> where(organization_id: ^organization_id)
|> order_by(:provider)
|> Repo.all()
end
def get_integration(organization_id, provider) do
case Repo.get_by(Integration, organization_id: organization_id, provider: provider) do
nil -> {:error, :not_found}
integration -> {:ok, integration}
end
end
def get_integration!(organization_id, provider) do
Repo.get_by!(Integration, organization_id: organization_id, provider: provider)
end
def create_integration(organization_id, attrs) do
%Integration{}
|> Integration.changeset(Map.put(attrs, :organization_id, organization_id))
|> Repo.insert()
end
def update_integration(%Integration{} = integration, attrs) do
integration
|> Integration.changeset(attrs)
|> Repo.update()
end
def update_sync_status(%Integration{} = integration, status) do
integration
|> Integration.changeset(%{
last_sync_status: status,
last_synced_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
|> Repo.update()
end
def delete_integration(%Integration{} = integration) do
Repo.delete(integration)
end
def change_integration(%Integration{} = integration, attrs \\ %{}) do
Integration.changeset(integration, attrs)
end
def list_enabled_integrations(provider) do
Integration
|> where(provider: ^provider, enabled: true)
|> Repo.all()
end
end
Step 4: Run test to verify it passes
Run: mix test test/towerops/integrations_test.exs
Expected: All tests pass
Step 5: Commit
git add lib/towerops/integrations.ex test/towerops/integrations_test.exs
git commit -m "add integrations context with CRUD operations"
Task 4: Create Preseem API Client
Files:
- Create:
lib/towerops/preseem/client.ex - Test:
test/towerops/preseem/client_test.exs
Step 1: Write failing client test
The client calls the Preseem API. For testing, we use Req.Test (built-in test adapter for Req).
# test/towerops/preseem/client_test.exs
defmodule Towerops.Preseem.ClientTest do
use ExUnit.Case, async: true
alias Towerops.Preseem.Client
describe "test_connection/1" do
test "returns ok when API responds with 200" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{"status" => "ok", "account" => "test-isp"})
end)
assert {:ok, %{"status" => "ok"}} = Client.test_connection("valid-api-key")
end
test "returns error for 401 unauthorized" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
assert {:error, :unauthorized} = Client.test_connection("bad-key")
end
test "returns error for 403 forbidden" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(403)
|> Req.Test.json(%{"error" => "forbidden"})
end)
assert {:error, :forbidden} = Client.test_connection("restricted-key")
end
test "returns error for network failure" do
Req.Test.stub(Client, fn _conn ->
raise "connection refused"
end)
assert {:error, _reason} = Client.test_connection("any-key")
end
end
describe "list_access_points/1" do
test "returns list of access points on success" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"data" => [
%{"id" => "ap-1", "name" => "Tower1-AP1", "ip" => "10.0.0.1"},
%{"id" => "ap-2", "name" => "Tower1-AP2", "ip" => "10.0.0.2"}
]
})
end)
assert {:ok, [ap1, ap2]} = Client.list_access_points("valid-key")
assert ap1["id"] == "ap-1"
assert ap2["id"] == "ap-2"
end
test "returns error for 401" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
assert {:error, :unauthorized} = Client.list_access_points("bad-key")
end
end
describe "get_access_point_metrics/2" do
test "returns metrics for an AP" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"data" => %{
"qoe_score" => 85.5,
"capacity_score" => 72.0,
"subscriber_count" => 42,
"p95_latency" => 18.3
}
})
end)
assert {:ok, metrics} = Client.get_access_point_metrics("valid-key", "ap-1")
assert metrics["qoe_score"] == 85.5
end
end
end
Step 2: Run test to verify it fails
Run: mix test test/towerops/preseem/client_test.exs
Expected: Compilation error — module Towerops.Preseem.Client not found
Step 3: Write the client
# lib/towerops/preseem/client.ex
defmodule Towerops.Preseem.Client do
@moduledoc """
HTTP client for the Preseem Model API.
Uses Req with plug-based testing support via `Req.Test`.
"""
require Logger
@default_base_url "https://apidocs.preseem.com/model/v1"
def test_connection(api_key, opts \\ []) do
base_url = Keyword.get(opts, :base_url, @default_base_url)
case request(:get, "#{base_url}/account", api_key) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: status, body: body}} ->
Logger.warning("Preseem API unexpected status #{status}: #{inspect(body)}")
{:error, {:unexpected_status, status}}
{:error, reason} ->
Logger.error("Preseem API connection error: #{inspect(reason)}")
{:error, reason}
end
end
def list_access_points(api_key, opts \\ []) do
base_url = Keyword.get(opts, :base_url, @default_base_url)
case request(:get, "#{base_url}/access_points", api_key) do
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
{:ok, data}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: status, body: body}} ->
{:error, {:unexpected_status, status, body}}
{:error, reason} ->
{:error, reason}
end
end
def get_access_point_metrics(api_key, ap_id, opts \\ []) do
base_url = Keyword.get(opts, :base_url, @default_base_url)
case request(:get, "#{base_url}/access_points/#{ap_id}/metrics", api_key) do
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
{:ok, data}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: status, body: body}} ->
{:error, {:unexpected_status, status, body}}
{:error, reason} ->
{:error, reason}
end
end
defp request(method, url, api_key) do
req =
Req.new(
method: method,
url: url,
headers: [
{"authorization", "Bearer #{api_key}"},
{"accept", "application/json"}
],
plug: {Req.Test, __MODULE__}
)
Req.request(req)
rescue
exception ->
{:error, Exception.message(exception)}
end
end
Step 4: Run test to verify it passes
Run: mix test test/towerops/preseem/client_test.exs
Expected: All tests pass
Step 5: Commit
git add lib/towerops/preseem/client.ex test/towerops/preseem/client_test.exs
git commit -m "add preseem API client with test connection support"
Task 5: Create Integrations LiveView (Settings Page)
Files:
- Create:
lib/towerops_web/live/org/integrations_live.ex - Create:
lib/towerops_web/live/org/integrations_live.html.heex - Modify:
lib/towerops_web/router.ex(add route) - Modify:
lib/towerops_web/live/org/settings_live.html.heex(add link) - Test:
test/towerops_web/live/org/integrations_live_test.exs
Step 1: Add route to router
In lib/towerops_web/router.ex, find the :require_authenticated_user_and_organization live_session block (around line 300-312) and add the integrations route next to the existing settings route:
# Find this block:
scope "/orgs/:org_slug", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
live "/settings", Org.SettingsLive, :index
end
# Change to:
scope "/orgs/:org_slug", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
live "/settings", Org.SettingsLive, :index
live "/settings/integrations", Org.IntegrationsLive, :index
end
Step 2: Write failing LiveView test
# test/towerops_web/live/org/integrations_live_test.exs
defmodule ToweropsWeb.Org.IntegrationsLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
setup %{conn: conn} do
user = user_fixture()
organization = organization_fixture(user.id)
conn = log_in_user(conn, user)
%{conn: conn, user: user, organization: organization}
end
describe "index" do
test "renders integrations page", %{conn: conn, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")
assert html =~ "Integrations"
assert html =~ "Preseem"
end
test "shows preseem as available integration", %{conn: conn, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")
assert html =~ "Preseem"
assert html =~ "QoE monitoring"
end
test "can enable preseem integration", %{conn: conn, organization: org} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")
html =
view
|> element("#configure-preseem")
|> render_click()
assert html =~ "API Key"
end
test "can save preseem credentials", %{conn: conn, organization: org} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")
# Open configure panel
view |> element("#configure-preseem") |> render_click()
# Fill in API key and save
view
|> form("#preseem-form", %{integration: %{api_key: "test-key-123"}})
|> render_submit()
# Verify it was saved
assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "preseem")
assert integration.credentials["api_key"] == "test-key-123"
end
test "can test preseem connection", %{conn: conn, organization: org} do
Req.Test.stub(Towerops.Preseem.Client, fn conn ->
Req.Test.json(conn, %{"status" => "ok"})
end)
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")
view |> element("#configure-preseem") |> render_click()
view
|> form("#preseem-form", %{integration: %{api_key: "test-key"}})
|> render_change()
html = view |> element("#test-connection") |> render_click()
assert html =~ "Connection successful"
end
test "shows error for invalid preseem API key", %{conn: conn, organization: org} do
Req.Test.stub(Towerops.Preseem.Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")
view |> element("#configure-preseem") |> render_click()
view
|> form("#preseem-form", %{integration: %{api_key: "bad-key"}})
|> render_change()
html = view |> element("#test-connection") |> render_click()
assert html =~ "unauthorized" or html =~ "Invalid API key"
end
end
end
Step 3: Run test to verify it fails
Run: mix test test/towerops_web/live/org/integrations_live_test.exs
Expected: Compilation error or route not found
Step 4: Write the LiveView module
# lib/towerops_web/live/org/integrations_live.ex
defmodule ToweropsWeb.Org.IntegrationsLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Integrations
alias Towerops.Preseem.Client, as: PreseemClient
@providers [
%{
id: "preseem",
name: "Preseem",
description: "QoE monitoring and AP capacity management for WISPs",
icon: "hero-signal"
}
]
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
integrations = Integrations.list_integrations(organization.id)
integrations_by_provider =
Map.new(integrations, fn i -> {i.provider, i} end)
{:ok,
socket
|> assign(:organization, organization)
|> assign(:providers, @providers)
|> assign(:integrations, integrations_by_provider)
|> assign(:configuring, nil)
|> assign(:form, nil)
|> assign(:test_result, nil)}
end
@impl true
def handle_event("configure", %{"provider" => provider}, socket) do
org = socket.assigns.organization
integration =
case Map.get(socket.assigns.integrations, provider) do
nil ->
%Integrations.Integration{
organization_id: org.id,
provider: provider
}
existing ->
existing
end
form = build_form(integration)
{:noreply,
socket
|> assign(:configuring, provider)
|> assign(:form, form)
|> assign(:test_result, nil)}
end
@impl true
def handle_event("close_config", _params, socket) do
{:noreply,
socket
|> assign(:configuring, nil)
|> assign(:form, nil)
|> assign(:test_result, nil)}
end
@impl true
def handle_event("validate", %{"integration" => params}, socket) do
changeset =
socket.assigns.form.source.data
|> Integrations.change_integration(normalize_params(params))
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset, as: :integration))}
end
@impl true
def handle_event("save", %{"integration" => params}, socket) do
org = socket.assigns.organization
provider = socket.assigns.configuring
normalized = normalize_params(params)
result =
case Map.get(socket.assigns.integrations, provider) do
nil ->
Integrations.create_integration(org.id, Map.put(normalized, :provider, provider))
existing ->
Integrations.update_integration(existing, normalized)
end
case result do
{:ok, integration} ->
integrations = Map.put(socket.assigns.integrations, provider, integration)
{:noreply,
socket
|> assign(:integrations, integrations)
|> assign(:form, build_form(integration))
|> put_flash(:info, "Integration saved")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :integration))}
end
end
@impl true
def handle_event("test_connection", _params, socket) do
api_key = get_api_key_from_form(socket)
if api_key && api_key != "" do
case PreseemClient.test_connection(api_key) do
{:ok, _} ->
{:noreply, assign(socket, :test_result, :success)}
{:error, :unauthorized} ->
{:noreply, assign(socket, :test_result, {:error, "Invalid API key"})}
{:error, :forbidden} ->
{:noreply, assign(socket, :test_result, {:error, "API key does not have access"})}
{:error, reason} ->
{:noreply,
assign(socket, :test_result, {:error, "Connection failed: #{inspect(reason)}"})}
end
else
{:noreply, assign(socket, :test_result, {:error, "Enter an API key first"})}
end
end
@impl true
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
case Map.get(socket.assigns.integrations, provider) do
nil ->
{:noreply, socket}
integration ->
case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
{:ok, updated} ->
integrations = Map.put(socket.assigns.integrations, provider, updated)
status = if updated.enabled, do: "enabled", else: "disabled"
{:noreply,
socket
|> assign(:integrations, integrations)
|> put_flash(:info, "Integration #{status}")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update integration")}
end
end
end
defp build_form(integration) do
integration
|> Integrations.change_integration()
|> to_form(as: :integration)
end
defp normalize_params(params) do
api_key = Map.get(params, "api_key", "")
base_url = Map.get(params, "base_url", "")
enabled = Map.get(params, "enabled", "false")
credentials =
%{"api_key" => api_key}
|> then(fn creds ->
if base_url != "", do: Map.put(creds, "base_url", base_url), else: creds
end)
%{
credentials: credentials,
enabled: enabled == "true" || enabled == true
}
end
defp get_api_key_from_form(socket) do
case socket.assigns.form do
nil ->
nil
form ->
changeset = form.source
data = Ecto.Changeset.apply_changes(changeset)
case data.credentials do
%{"api_key" => key} -> key
_ -> nil
end
end
end
end
Step 5: Write the template
<%!-- lib/towerops_web/live/org/integrations_live.html.heex --%>
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}/settings"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Integrations
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Connect third-party services to enhance monitoring and analytics
</p>
</div>
<div class="mt-8 space-y-6">
<%= for provider <- @providers do %>
<% integration = Map.get(@integrations, provider.id) %>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/20">
<.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{provider.name}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{provider.description}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<%= if integration do %>
<button
type="button"
phx-click="toggle_enabled"
phx-value-provider={provider.id}
class={[
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2",
if(integration.enabled, do: "bg-indigo-600", else: "bg-gray-200 dark:bg-gray-700")
]}
>
<span class={[
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
if(integration.enabled, do: "translate-x-5", else: "translate-x-0")
]} />
</button>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
>
Configure
</button>
</div>
</div>
<%= if integration do %>
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<span class={[
"inline-block h-2 w-2 rounded-full",
case integration.last_sync_status do
"success" -> "bg-green-500"
"partial" -> "bg-yellow-500"
"failed" -> "bg-red-500"
_ -> "bg-gray-400"
end
]} />
Last sync: <%= if integration.last_synced_at do %>
{Calendar.strftime(integration.last_synced_at, "%b %d, %Y %H:%M UTC")}
<% else %>
Never
<% end %>
</span>
<span>Status: {integration.last_sync_status}</span>
</div>
<% end %>
</div>
<% end %>
</div>
<%!-- Configuration Panel --%>
<%= if @configuring == "preseem" do %>
<div class="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 dark:bg-gray-800"
phx-click-away="close_config"
>
<div class="flex items-center justify-between border-b border-gray-200 pb-4 dark:border-white/10">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Configure Preseem
</h3>
<button
type="button"
phx-click="close_config"
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<.icon name="hero-x-mark" class="h-5 w-5" />
</button>
</div>
<.form
for={@form}
id="preseem-form"
phx-change="validate"
phx-submit="save"
class="mt-4 space-y-4"
>
<div>
<label
for="integration_api_key"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
API Key
</label>
<input
type="password"
id="integration_api_key"
name="integration[api_key]"
value={get_in(@form.source |> Ecto.Changeset.apply_changes() |> Map.from_struct(), [:credentials, "api_key"]) || ""}
class="mt-1 block w-full rounded-md border-gray-300 shadow-xs focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:border-white/20 dark:bg-white/5 dark:text-white"
placeholder="Enter your Preseem API key"
autocomplete="off"
/>
</div>
<div>
<label
for="integration_base_url"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Base URL <span class="text-gray-400">(optional)</span>
</label>
<input
type="text"
id="integration_base_url"
name="integration[base_url]"
value={get_in(@form.source |> Ecto.Changeset.apply_changes() |> Map.from_struct(), [:credentials, "base_url"]) || ""}
class="mt-1 block w-full rounded-md border-gray-300 shadow-xs focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:border-white/20 dark:bg-white/5 dark:text-white"
placeholder="https://apidocs.preseem.com/model/v1"
autocomplete="off"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Only change this if directed by Preseem support
</p>
</div>
<%!-- Test Connection Result --%>
<%= if @test_result == :success do %>
<div class="rounded-md bg-green-50 p-3 dark:bg-green-900/20">
<div class="flex items-center">
<.icon
name="hero-check-circle"
class="h-5 w-5 text-green-600 dark:text-green-400"
/>
<p class="ml-2 text-sm text-green-800 dark:text-green-200">
Connection successful
</p>
</div>
</div>
<% end %>
<%= case @test_result do %>
<% {:error, message} -> %>
<div class="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<div class="flex items-center">
<.icon
name="hero-exclamation-circle"
class="h-5 w-5 text-red-600 dark:text-red-400"
/>
<p class="ml-2 text-sm text-red-800 dark:text-red-200">
{message}
</p>
</div>
</div>
<% _ -> %>
<% end %>
<div class="flex items-center justify-between border-t border-gray-200 pt-4 dark:border-white/10">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
>
Test Connection
</button>
<div class="flex gap-3">
<button
type="button"
phx-click="close_config"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
Save
</button>
</div>
</div>
</.form>
</div>
</div>
</div>
<div class="fixed inset-0 z-40 bg-gray-500/75 dark:bg-gray-900/75" />
<% end %>
</Layouts.authenticated>
Step 6: Add link from settings page
In lib/towerops_web/live/org/settings_live.html.heex, add a link to the integrations page. Find the closing </div> of the page header (around line 20, after the subtitle <p> tag) and add:
<%!-- Add after the subtitle <p> tag, inside the header div --%>
<div class="mt-4">
<.link
navigate={~p"/orgs/#{@organization.slug}/settings/integrations"}
class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
>
<.icon name="hero-puzzle-piece" class="h-4 w-4" /> Integrations
</.link>
</div>
Step 7: Run test to verify it passes
Run: mix test test/towerops_web/live/org/integrations_live_test.exs
Expected: All tests pass (some tests may need adjustment based on exact HTML output — iterate until green)
Step 8: Commit
git add lib/towerops_web/live/org/integrations_live.ex lib/towerops_web/live/org/integrations_live.html.heex lib/towerops_web/router.ex lib/towerops_web/live/org/settings_live.html.heex test/towerops_web/live/org/integrations_live_test.exs
git commit -m "add integrations settings page with preseem configuration"
Task 6: Create Integrations Test Fixture
Files:
- Create:
test/support/fixtures/integrations_fixtures.ex
Step 1: Write the fixture
# test/support/fixtures/integrations_fixtures.ex
defmodule Towerops.IntegrationsFixtures do
@moduledoc """
Test helpers for creating integration entities.
"""
alias Towerops.Integrations
def integration_fixture(organization_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
provider: "preseem",
enabled: true,
credentials: %{"api_key" => "test-key-#{System.unique_integer()}"}
})
{:ok, integration} = Integrations.create_integration(organization_id, attrs)
integration
end
end
Step 2: Commit
git add test/support/fixtures/integrations_fixtures.ex
git commit -m "add integrations test fixture"
Task 7: Format, Compile Clean, Run Full Test Suite
Step 1: Format
Run: mix format
Step 2: Compile with warnings as errors
Run: mix compile --warnings-as-errors
Expected: Clean compilation, no warnings
Step 3: Run full test suite
Run: mix test
Expected: Same failure count as baseline (19 pre-existing failures). No new failures.
Step 4: Fix any issues found
If new warnings or failures, fix them before committing.
Step 5: Final commit if any format/warning fixes needed
git add -A
git commit -m "fix formatting and compiler warnings"
Verification Checklist
After all tasks complete, verify:
mix compile --warnings-as-errors— cleanmix format --check-formatted— cleanmix test— no new failures vs baseline (19 pre-existing)mix test test/towerops/ecto_types/encrypted_map_test.exs— passesmix test test/towerops/integrations/integration_test.exs— passesmix test test/towerops/integrations_test.exs— passesmix test test/towerops/preseem/client_test.exs— passesmix test test/towerops_web/live/org/integrations_live_test.exs— passes- Navigate to
/orgs/:slug/settings— "Integrations" link visible - Navigate to
/orgs/:slug/settings/integrations— page renders with Preseem card - Click Configure → modal opens with API key field
- Save credentials → reloads with credentials stored
- Toggle enable/disable → updates integration state