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:
parent
c1b7ee7b6f
commit
74332e2203
7 changed files with 110 additions and 19 deletions
|
|
@ -1 +1 @@
|
|||
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
|
||||
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue