fix: Gaiia and Preseem sync bugs from API response mismatches

- Billing subscription and inventory item status fields are scalars, not objects
- IP blocks use "block" key, not "subnet"
- Strip nil variables from GraphQL pagination (Gaiia returns null for after:null)
- Handle nil root keys in paginated responses gracefully
- Handle empty string response from Preseem scores endpoint
This commit is contained in:
Graham McIntire 2026-02-15 12:23:29 -06:00
parent c1b7ee7b6f
commit 74332e2203
No known key found for this signature in database
7 changed files with 110 additions and 19 deletions

View file

@ -1 +1 @@
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json

View file

@ -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}

View file

@ -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"]),

View file

@ -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

View file

@ -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,

View file

@ -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"
}
}
],

View file

@ -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)