diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3498c83d..173daa95 120000 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1 +1 @@ -/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json \ No newline at end of file +/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json \ No newline at end of file diff --git a/lib/towerops/gaiia/client.ex b/lib/towerops/gaiia/client.ex index b21440f6..b9807070 100644 --- a/lib/towerops/gaiia/client.ex +++ b/lib/towerops/gaiia/client.ex @@ -94,10 +94,7 @@ defmodule Towerops.Gaiia.Client do edges { node { id - status { - id - name - } + status ipAddressV4 model { name @@ -273,17 +270,21 @@ defmodule Towerops.Gaiia.Client do end defp paginate(api_key, query_string, root_key, opts, cursor \\ nil, acc \\ []) do - variables = %{"first" => 100, "after" => cursor} + variables = + %{"first" => 100, "after" => cursor} + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() case query(api_key, query_string, variables, opts) do {:ok, data} -> root = data[root_key] - nodes = Enum.map(root["edges"], & &1["node"]) + edges = (root && root["edges"]) || [] + nodes = Enum.map(edges, & &1["node"]) all_nodes = acc ++ nodes - page_info = root["pageInfo"] + page_info = root && root["pageInfo"] - if page_info["hasNextPage"] do + if page_info && page_info["hasNextPage"] do paginate(api_key, query_string, root_key, opts, page_info["endCursor"], all_nodes) else {:ok, all_nodes} diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index f9c5d544..47eab447 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -83,7 +83,7 @@ defmodule Towerops.Gaiia.Sync do defp map_account(node) do subs = get_in(node, ["billingSubscriptions", "edges"]) || [] - active_count = Enum.count(subs, &(get_in(&1, ["node", "status", "name"]) == "Active")) + active_count = Enum.count(subs, &(&1["node"]["status"] == "ACTIVE")) %{ gaiia_id: node["id"], @@ -99,7 +99,7 @@ defmodule Towerops.Gaiia.Sync do defp map_network_site(node) do ip_block_edges = get_in(node, ["ipBlocks", "edges"]) || [] - ip_blocks = Enum.map(ip_block_edges, & &1["node"]["subnet"]) + ip_blocks = Enum.map(ip_block_edges, & &1["node"]["block"]) %{ gaiia_id: node["id"], @@ -123,7 +123,7 @@ defmodule Towerops.Gaiia.Sync do %{ gaiia_id: node["id"], name: get_in(node, ["model", "name"]), - status: get_in(node, ["status", "name"]), + status: node["status"], serial_number: nil, mac_address: nil, ip_address: node["ipAddressV4"], @@ -146,7 +146,7 @@ defmodule Towerops.Gaiia.Sync do %{ gaiia_id: node["id"], account_gaiia_id: get_in(node, ["entity", "id"]), - status: get_in(node, ["status", "name"]), + status: node["status"], product_name: get_in(node, ["productVersion", "product", "name"]), mrr_amount: mrr_amount, currency: get_in(node, ["productVersion", "currency"]), diff --git a/lib/towerops/preseem/client.ex b/lib/towerops/preseem/client.ex index 9f67ff18..6d19128c 100644 --- a/lib/towerops/preseem/client.ex +++ b/lib/towerops/preseem/client.ex @@ -100,6 +100,8 @@ defmodule Towerops.Preseem.Client do # The scores endpoint may return an array, a wrapped object with "data" key, # or a single object. Normalize to a list. + defp normalize_scores_response(body) when is_binary(body), do: [] + defp normalize_scores_response(body) when is_list(body), do: body defp normalize_scores_response(%{"data" => data}) when is_list(data), do: data diff --git a/test/towerops/gaiia/sync_test.exs b/test/towerops/gaiia/sync_test.exs index 9c600437..70b4d906 100644 --- a/test/towerops/gaiia/sync_test.exs +++ b/test/towerops/gaiia/sync_test.exs @@ -79,7 +79,7 @@ defmodule Towerops.Gaiia.SyncTest do [sub] = Gaiia.list_billing_subscriptions(org.id) assert sub.gaiia_id == "sub-1" assert sub.account_gaiia_id == "acct-1" - assert sub.status == "Active" + assert sub.status == "ACTIVE" assert sub.product_name == "100Mbps Unlimited" end @@ -96,6 +96,60 @@ defmodule Towerops.Gaiia.SyncTest do assert updated.last_sync_status == "failed" end + test "handles API that returns null when after variable is null", %{ + org: org, + integration: integration + } do + # Some Gaiia endpoints return null for the root key when $after is null + # (e.g. networkSites, inventoryItems). The client should handle this + # by omitting nil variables from the request. + Req.Test.stub(Client, fn conn -> + {:ok, body, _conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + vars = decoded["variables"] + + response = + if String.contains?(query, "ListNetworkSites") do + # Simulate Gaiia behavior: null when $after is null in variables + if Map.get(vars, "after") == nil and Map.has_key?(vars, "after") do + %{"data" => %{"networkSites" => nil}} + else + network_sites_response() + end + else + empty_relay_response(query) + end + + Req.Test.json(conn, response) + end) + + {:ok, result} = Sync.sync_organization(integration) + # With nil variables stripped, the API returns real data + assert result.network_sites == 1 + end + + test "handles nil root key in API response gracefully", %{integration: integration} do + Req.Test.stub(Client, fn conn -> + {:ok, body, _conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + response = + if String.contains?(query, "ListAccounts") do + # API returns nil for the root key + %{"data" => %{"accounts" => nil}} + else + empty_relay_response(query) + end + + Req.Test.json(conn, response) + end) + + assert {:ok, result} = Sync.sync_organization(integration) + assert result.accounts == 0 + end + test "upserts existing records without duplicating", %{org: org, integration: integration} do stub_all_endpoints() @@ -154,7 +208,7 @@ defmodule Towerops.Gaiia.SyncTest do %{ "node" => %{ "id" => "sub-1", - "status" => %{"id" => "sub-status-1", "name" => "Active"} + "status" => "ACTIVE" } } ] @@ -188,7 +242,7 @@ defmodule Towerops.Gaiia.SyncTest do }, "ipBlocks" => %{ "edges" => [ - %{"node" => %{"subnet" => "10.0.0.0/24"}} + %{"node" => %{"id" => "ipblock-1", "block" => "10.0.0.0/24"}} ] } } @@ -208,7 +262,7 @@ defmodule Towerops.Gaiia.SyncTest do %{ "node" => %{ "id" => "item-1", - "status" => %{"id" => "inv-status-1", "name" => "Active"}, + "status" => "ACTIVE", "ipAddressV4" => "10.0.0.1", "model" => %{ "name" => "airFiber 5XHD", @@ -228,6 +282,26 @@ defmodule Towerops.Gaiia.SyncTest do } end + defp empty_relay_response(query) do + root_key = + cond do + String.contains?(query, "ListAccounts") -> "accounts" + String.contains?(query, "ListNetworkSites") -> "networkSites" + String.contains?(query, "ListInventoryItems") -> "inventoryItems" + String.contains?(query, "ListBillingSubscriptions") -> "billingSubscriptions" + true -> "unknown" + end + + %{ + "data" => %{ + root_key => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + end + defp billing_subscriptions_response do %{ "data" => %{ @@ -236,7 +310,7 @@ defmodule Towerops.Gaiia.SyncTest do %{ "node" => %{ "id" => "sub-1", - "status" => %{"id" => "sub-status-1", "name" => "Active"}, + "status" => "ACTIVE", "productVersion" => %{ "product" => %{"name" => "100Mbps Unlimited"}, "price" => 7999, diff --git a/test/towerops/workers/gaiia_sync_worker_test.exs b/test/towerops/workers/gaiia_sync_worker_test.exs index 96b923d7..8383055b 100644 --- a/test/towerops/workers/gaiia_sync_worker_test.exs +++ b/test/towerops/workers/gaiia_sync_worker_test.exs @@ -64,7 +64,8 @@ defmodule Towerops.Workers.GaiiaSyncWorkerTest do "name" => "Test Account", "status" => %{"id" => "status-1", "name" => "Active", "type" => "ACTIVE"}, "type" => %{"id" => "type-1", "name" => "Residential"}, - "billingSubscriptions" => %{"edges" => []} + "billingSubscriptions" => %{"edges" => []}, + "readableId" => "1001" } } ], diff --git a/test/towerops/workers/preseem_sync_worker_test.exs b/test/towerops/workers/preseem_sync_worker_test.exs index 645abf04..2ae440b0 100644 --- a/test/towerops/workers/preseem_sync_worker_test.exs +++ b/test/towerops/workers/preseem_sync_worker_test.exs @@ -138,6 +138,19 @@ defmodule Towerops.Workers.PreseemSyncWorkerTest do assert updated.last_sync_status in ["success", "partial"] end + test "handles empty string response body from API", %{org: org} do + integration = integration_fixture(org.id) + + Req.Test.stub(Client, fn conn -> + # Preseem sometimes returns an empty string for the scores endpoint + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, ~s("")) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{"integration_id" => integration.id}) + end + test "handles API errors without crashing", %{org: org} do integration = integration_fixture(org.id)