diff --git a/test/towerops/gaiia/client_test.exs b/test/towerops/gaiia/client_test.exs index 2271f1a7..de5ceeb8 100644 --- a/test/towerops/gaiia/client_test.exs +++ b/test/towerops/gaiia/client_test.exs @@ -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 diff --git a/test/towerops/gaiia/sync_test.exs b/test/towerops/gaiia/sync_test.exs index 70b4d906..d53631fb 100644 --- a/test/towerops/gaiia/sync_test.exs +++ b/test/towerops/gaiia/sync_test.exs @@ -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 diff --git a/test/towerops/splynx/client_test.exs b/test/towerops/splynx/client_test.exs new file mode 100644 index 00000000..0344b159 --- /dev/null +++ b/test/towerops/splynx/client_test.exs @@ -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 diff --git a/test/towerops/splynx/sync_test.exs b/test/towerops/splynx/sync_test.exs new file mode 100644 index 00000000..2631d217 --- /dev/null +++ b/test/towerops/splynx/sync_test.exs @@ -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