Merge remote-tracking branch 'origin/main' into gaiia

This commit is contained in:
Graham McIntire 2026-02-15 17:17:10 -06:00
commit f7dc546bcf
No known key found for this signature in database
6 changed files with 651 additions and 4 deletions

View file

@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: towerops
image: git.mcintire.me/graham/towerops-web:main-1771190642-e6665ec # {"$imagepolicy": "flux-system:towerops-web"}
image: git.mcintire.me/graham/towerops-web:main-1771194965-074c55c # {"$imagepolicy": "flux-system:towerops-web"}
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false

View file

@ -199,8 +199,11 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
if matches == [], do: acc, else: Map.put(acc, site.gaiia_id, matches)
end)
# Sort items with suggestions first
sorted_sites = Enum.sort_by(sites, fn site -> if Map.has_key?(suggestions, site.gaiia_id), do: 0, else: 1 end)
socket
|> assign(:items, sites)
|> assign(:items, sorted_sites)
|> assign(:suggestions, suggestions)
end
@ -217,8 +220,11 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
if matches == [], do: acc, else: Map.put(acc, item.gaiia_id, matches)
end)
# Sort items with suggestions first
sorted_items = Enum.sort_by(items, fn item -> if Map.has_key?(suggestions, item.gaiia_id), do: 0, else: 1 end)
socket
|> assign(:items, items)
|> assign(:items, sorted_items)
|> assign(:suggestions, suggestions)
end

View file

@ -193,5 +193,70 @@ defmodule Towerops.Gaiia.ClientTest do
assert {:error, {:rate_limited, 30}} = Client.query("test-key", "{ viewer { id } }")
end
test "handles unexpected status codes" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(502)
|> Req.Test.json(%{"error" => "bad gateway"})
end)
assert {:error, {:unexpected_status, 502}} = Client.query("test-key", "{ viewer { id } }")
end
test "sends correct headers" do
Req.Test.stub(Client, fn conn ->
api_key = Plug.Conn.get_req_header(conn, "x-gaiia-api-key")
assert api_key == ["my-api-key"]
content_type = Plug.Conn.get_req_header(conn, "content-type")
assert content_type == ["application/json"]
Req.Test.json(conn, %{"data" => %{}})
end)
Client.query("my-api-key", "{ viewer { id } }")
end
end
describe "create_ticket/3" do
test "creates a ticket via mutation" do
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
assert String.contains?(decoded["query"], "createExternalTicket")
assert decoded["variables"]["subject"] == "Test ticket"
Req.Test.json(conn, %{
"data" => %{
"createExternalTicket" => %{
"id" => "ticket-1",
"subject" => "Test ticket"
}
}
})
end)
assert {:ok, data} = Client.create_ticket("test-key", %{"subject" => "Test ticket", "description" => "Details"})
assert data["createExternalTicket"]["id"] == "ticket-1"
end
end
describe "create_note/5" do
test "creates a note on an entity" do
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
assert decoded["variables"]["entityId"] == "acct-1"
assert decoded["variables"]["entityType"] == "Account"
assert decoded["variables"]["body"] == "Test note"
Req.Test.json(conn, %{
"data" => %{"createNote" => %{"id" => "note-1"}}
})
end)
assert {:ok, _} = Client.create_note("test-key", "Account", "acct-1", "Test note")
end
end
end

View file

@ -97,7 +97,7 @@ defmodule Towerops.Gaiia.SyncTest do
end
test "handles API that returns null when after variable is null", %{
org: org,
org: _org,
integration: integration
} do
# Some Gaiia endpoints return null for the root key when $after is null

View file

@ -0,0 +1,366 @@
defmodule Towerops.Splynx.ClientTest do
use Towerops.DataCase, async: true
alias Towerops.Splynx.Client
@instance_url "https://demo.splynx.com"
@api_key "test_api_key"
@api_secret "test_api_secret"
describe "authenticate/3" do
test "succeeds with api_key auth type" do
Req.Test.stub(Client, fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
assert decoded["auth_type"] == "api_key"
assert decoded["key"] == @api_key
assert decoded["secret"] == @api_secret
conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(auth_success_response())
end)
assert {:ok, "test_jwt_token_abc123"} = Client.authenticate(@instance_url, @api_key, @api_secret)
end
test "falls back to admin auth when api_key auth fails" do
call_count = :counters.new(1, [:atomics])
Req.Test.stub(Client, fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
:counters.add(call_count, 1, 1)
count = :counters.get(call_count, 1)
if count == 1 do
# First call: api_key auth fails
assert decoded["auth_type"] == "api_key"
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => %{"code" => 401, "message" => "Authentication failed!"}})
else
# Second call: admin auth succeeds
assert decoded["auth_type"] == "admin"
assert decoded["login"] == @api_key
assert decoded["password"] == @api_secret
conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(auth_success_response())
end
end)
assert {:ok, "test_jwt_token_abc123"} = Client.authenticate(@instance_url, @api_key, @api_secret)
end
test "returns error when both auth types fail" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => %{"code" => 401, "message" => "Authentication failed!"}})
end)
assert {:error, :unauthorized} = Client.authenticate(@instance_url, "bad", "creds")
end
test "returns error on rate limit" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(429)
|> Req.Test.json(%{"error" => "rate limited"})
end)
assert {:error, {:rate_limited, 60}} = Client.authenticate(@instance_url, @api_key, @api_secret)
end
end
describe "test_connection/3" do
test "returns ok on successful connection" do
stub_auth_then(fn conn ->
Req.Test.json(conn, [%{"id" => 1, "name" => "Clark Willis"}])
end)
assert {:ok, %{}} = Client.test_connection(@instance_url, @api_key, @api_secret)
end
test "returns error on unauthorized" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => %{"code" => 401, "message" => "Authentication failed!"}})
end)
assert {:error, :unauthorized} = Client.test_connection(@instance_url, "bad", "creds")
end
end
describe "list_customers/3" do
test "returns customers from single page" do
stub_auth_then(fn conn ->
Req.Test.json(conn, customers_response())
end)
assert {:ok, customers} = Client.list_customers(@instance_url, @api_key, @api_secret)
assert length(customers) == 3
assert Enum.map(customers, & &1["name"]) == ["Clark Willis", "Eugene Castro", "Nelson Jackson"]
end
test "handles paginated results" do
call_count = :counters.new(1, [:atomics])
stub_auth_then(fn conn ->
:counters.add(call_count, 1, 1)
count = :counters.get(call_count, 1)
if count == 1 do
# Return exactly 100 to trigger pagination
items = Enum.map(1..100, fn i ->
%{"id" => i, "name" => "Customer #{i}", "status" => "active", "mrr_total" => "50.0000"}
end)
Req.Test.json(conn, items)
else
# Second page: less than 100, stops pagination
Req.Test.json(conn, [
%{"id" => 101, "name" => "Customer 101", "status" => "active", "mrr_total" => "50.0000"}
])
end
end)
assert {:ok, customers} = Client.list_customers(@instance_url, @api_key, @api_secret)
assert length(customers) == 101
end
test "returns error on API failure" do
stub_auth_then(fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
assert {:error, :unauthorized} = Client.list_customers(@instance_url, @api_key, @api_secret)
end
test "verifies customer response shape matches real API" do
stub_auth_then(fn conn ->
Req.Test.json(conn, customers_response())
end)
{:ok, [first | _]} = Client.list_customers(@instance_url, @api_key, @api_secret)
# These fields come from the real Splynx demo API
assert first["id"] == 1
assert first["name"] == "Clark Willis"
assert first["status"] == "active"
assert first["mrr_total"] == "200.0000"
assert first["billing_type"] == "recurring"
assert first["email"] == "clark.willis@ispframework.com"
assert first["city"] == "Praha 5"
end
end
describe "list_routers/3" do
test "returns routers" do
stub_auth_then(fn conn ->
Req.Test.json(conn, routers_response())
end)
assert {:ok, routers} = Client.list_routers(@instance_url, @api_key, @api_secret)
assert length(routers) == 2
assert hd(routers)["title"] == "WiFi router"
assert hd(routers)["ip"] == "10.0.20.1"
end
end
describe "list_customer_services/4" do
test "returns services for a customer" do
stub_auth_then(fn conn ->
Req.Test.json(conn, customer_services_response())
end)
assert {:ok, services} = Client.list_customer_services(@instance_url, @api_key, @api_secret, 1)
assert length(services) == 1
[service] = services
assert service["description"] == "Internet - ADSL 2MB/1MB"
assert service["unit_price"] == "200.0000"
assert service["status"] == "active"
assert service["customer_id"] == 1
end
end
describe "authorization header" do
test "uses Splynx-EA format with access token" do
Req.Test.stub(Client, fn conn ->
case conn.request_path do
"/api/2.0/admin/auth/tokens" ->
conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(auth_success_response())
_ ->
# Verify the auth header format
auth = Plug.Conn.get_req_header(conn, "authorization")
assert auth == ["Splynx-EA (access_token=test_jwt_token_abc123)"]
Req.Test.json(conn, [%{"id" => 1}])
end
end)
Client.list_customers(@instance_url, @api_key, @api_secret)
end
end
# --- Helpers ---
defp stub_auth_then(data_fn) do
Req.Test.stub(Client, fn conn ->
case conn.request_path do
"/api/2.0/admin/auth/tokens" ->
conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(auth_success_response())
_ ->
data_fn.(conn)
end
end)
end
# Real response shapes captured from demo.splynx.com
defp auth_success_response do
%{
"access_token" => "test_jwt_token_abc123",
"refresh_token" => "test_refresh_token_abc123",
"access_token_expiration" => 1_771_195_460,
"refresh_token_expiration" => 1_771_798_460,
"permissions" => %{
"admin\\customers\\Customer" => %{
"index" => "allow",
"view" => "allow"
}
}
}
end
defp customers_response do
[
%{
"id" => 1,
"name" => "Clark Willis",
"email" => "clark.willis@ispframework.com",
"billing_email" => "clark.willis@ispframework.com",
"phone" => "",
"status" => "active",
"billing_type" => "recurring",
"mrr_total" => "200.0000",
"partner_id" => 2,
"location_id" => 1,
"login" => "000001",
"category" => "person",
"street_1" => "Voskovcova 1130/28",
"zip_code" => "15200",
"city" => "Praha 5",
"date_add" => "2015-10-21",
"account_type" => "regular"
},
%{
"id" => 2,
"name" => "Eugene Castro",
"email" => "eugene.castro@ispframework.com",
"billing_email" => "eugene.castro@ispframework.com",
"phone" => "",
"status" => "disabled",
"billing_type" => "recurring",
"mrr_total" => "0.0000",
"partner_id" => 1,
"location_id" => 2,
"login" => "000002",
"category" => "person",
"street_1" => "Ostrovskeho 1194/21",
"zip_code" => "15000",
"city" => "Praha 5",
"date_add" => "2015-10-21",
"account_type" => "regular"
},
%{
"id" => 3,
"name" => "Nelson Jackson",
"email" => "nelson.jackson@ispframework.com",
"billing_email" => "nelson.jackson@ispframework.com",
"phone" => "",
"status" => "active",
"billing_type" => "recurring",
"mrr_total" => "300.0000",
"partner_id" => 1,
"location_id" => 3,
"login" => "000003",
"category" => "person",
"street_1" => "Jeremiasova 2631/16c",
"zip_code" => "15500",
"city" => "Praha 5",
"date_add" => "2015-10-21",
"account_type" => "regular"
}
]
end
defp routers_response do
[
%{
"id" => 1,
"title" => "WiFi router",
"model" => "RB n",
"location_id" => 1,
"address" => "",
"ip" => "10.0.20.1",
"gps" => nil,
"authorization_method" => "dhcp_leases",
"accounting_method" => "mikrotik_api",
"nas_type" => 1,
"status" => nil,
"partners_ids" => [1, 2]
},
%{
"id" => 2,
"title" => "Demo CHR",
"model" => "",
"location_id" => 5,
"address" => "",
"ip" => "10.10.10.12",
"gps" => nil,
"authorization_method" => "ppp_secrets",
"accounting_method" => "mikrotik_api",
"nas_type" => 1,
"status" => nil,
"partners_ids" => [1, 2]
}
]
end
defp customer_services_response do
[
%{
"id" => 4,
"type" => "internet",
"customer_id" => 1,
"tariff_id" => 2,
"description" => "Internet - ADSL 2MB/1MB",
"quantity" => 1,
"unit_price" => "200.0000",
"start_date" => "2026-01-01",
"end_date" => "0000-00-00",
"status" => "active",
"login" => "000001",
"router_id" => 0,
"mac" => "",
"ipv4" => "",
"taking_ipv4" => "0"
}
]
end
end

View file

@ -0,0 +1,210 @@
defmodule Towerops.Splynx.SyncTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.IntegrationsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Splynx.Client
alias Towerops.Splynx.Sync
setup do
user = user_fixture()
org = organization_fixture(user.id)
integration =
integration_fixture(org.id, %{
provider: "splynx",
credentials: %{
"instance_url" => "https://demo.splynx.com",
"api_key" => "test_key",
"api_secret" => "test_secret"
}
})
%{org: org, integration: integration}
end
describe "sync_organization/1" do
test "syncs customers and routers", %{integration: integration} do
stub_splynx_api()
assert {:ok, result} = Sync.sync_organization(integration)
assert result.customers == 3
assert result.routers == 2
updated = Repo.reload!(integration)
assert updated.last_sync_status == "success"
assert updated.last_synced_at
assert updated.last_sync_message =~ "3 customers"
assert updated.last_sync_message =~ "2 active"
assert updated.last_sync_message =~ "2 routers"
end
test "calculates MRR from customer mrr_total fields", %{integration: integration} do
stub_splynx_api()
{:ok, _} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
# 200.0000 + 0.0000 + 300.0000 = 500.00
assert updated.last_sync_message =~ "MRR: $500.00"
end
test "marks sync as failed on auth error", %{integration: integration} do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => %{"code" => 401, "message" => "Authentication failed!"}})
end)
assert {:error, :unauthorized} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
assert updated.last_sync_status == "failed"
assert updated.last_sync_message =~ "Authentication failed"
end
test "marks sync as failed on rate limit", %{integration: integration} do
stub_auth_then(fn conn ->
conn
|> Plug.Conn.put_status(429)
|> Req.Test.json(%{"error" => "rate limited"})
end)
assert {:error, {:rate_limited, 60}} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
assert updated.last_sync_status == "failed"
assert updated.last_sync_message =~ "Rate limited"
end
test "handles zero MRR gracefully", %{integration: integration} do
stub_auth_then_routes(%{
"customer" => [%{"id" => 1, "name" => "Test", "status" => "active", "mrr_total" => "0.0000"}],
"routers" => []
})
{:ok, _} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
# Should not contain MRR section when total is 0
refute updated.last_sync_message =~ "MRR"
end
test "handles customers with nil mrr_total", %{integration: integration} do
stub_auth_then_routes(%{
"customer" => [%{"id" => 1, "name" => "Test", "status" => "active", "mrr_total" => nil}],
"routers" => []
})
{:ok, result} = Sync.sync_organization(integration)
assert result.customers == 1
end
test "handles customers with numeric mrr_total", %{integration: integration} do
stub_auth_then_routes(%{
"customer" => [%{"id" => 1, "name" => "Test", "status" => "active", "mrr_total" => 150.0}],
"routers" => []
})
{:ok, _} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
assert updated.last_sync_message =~ "MRR: $150.00"
end
end
# --- Helpers ---
defp stub_splynx_api do
stub_auth_then_routes(%{
"customer" => customers_response(),
"routers" => routers_response()
})
end
defp stub_auth_then(data_fn) do
Req.Test.stub(Client, fn conn ->
case conn.request_path do
"/api/2.0/admin/auth/tokens" ->
conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(auth_success_response())
_ ->
data_fn.(conn)
end
end)
end
defp stub_auth_then_routes(routes) do
Req.Test.stub(Client, fn conn ->
case conn.request_path do
"/api/2.0/admin/auth/tokens" ->
conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(auth_success_response())
path ->
data =
cond do
String.contains?(path, "/customer") -> Map.get(routes, "customer", [])
String.contains?(path, "/routers") -> Map.get(routes, "routers", [])
true -> []
end
Req.Test.json(conn, data)
end
end)
end
defp auth_success_response do
%{
"access_token" => "test_jwt_token",
"refresh_token" => "test_refresh",
"access_token_expiration" => 1_771_195_460,
"refresh_token_expiration" => 1_771_798_460,
"permissions" => %{}
}
end
defp customers_response do
[
%{
"id" => 1,
"name" => "Clark Willis",
"status" => "active",
"mrr_total" => "200.0000",
"billing_type" => "recurring",
"email" => "clark.willis@ispframework.com",
"city" => "Praha 5"
},
%{
"id" => 2,
"name" => "Eugene Castro",
"status" => "disabled",
"mrr_total" => "0.0000",
"billing_type" => "recurring",
"email" => "eugene.castro@ispframework.com",
"city" => "Praha 5"
},
%{
"id" => 3,
"name" => "Nelson Jackson",
"status" => "active",
"mrr_total" => "300.0000",
"billing_type" => "recurring",
"email" => "nelson.jackson@ispframework.com",
"city" => "Praha 5"
}
]
end
defp routers_response do
[
%{"id" => 1, "title" => "WiFi router", "ip" => "10.0.20.1"},
%{"id" => 2, "title" => "Demo CHR", "ip" => "10.10.10.12"}
]
end
end