towerops/lib/towerops/sonar/sync.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

99 lines
3.4 KiB
Elixir

defmodule Towerops.Sonar.Sync do
@moduledoc """
Orchestrates syncing data from the Sonar GraphQL API into the local database.
Pulls accounts, network sites, and inventory items from Sonar
and updates integration sync status.
"""
alias Towerops.Integrations
alias Towerops.Sonar.Client
require Logger
@doc """
Main entry point: syncs all entity types for the given integration.
Returns `{:ok, %{accounts: n, network_sites: n, inventory_items: n}}`
or `{:error, reason}`.
"""
def sync_organization(%Integrations.Integration{} = integration) do
instance_url = integration.credentials["instance_url"]
api_token = integration.credentials["api_token"]
with {:ok, accounts} <- Client.list_accounts(instance_url, api_token),
{:ok, sites} <- Client.list_network_sites(instance_url, api_token),
{:ok, items} <- Client.list_inventory_items(instance_url, api_token) do
accounts_count = length(accounts)
sites_count = length(sites)
items_count = length(items)
mrr = calculate_total_mrr(accounts)
message =
"Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items" <>
if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "")
Integrations.update_sync_status(integration, "success", message)
Integrations.update_billing_totals(integration, accounts_count, Decimal.from_float(mrr))
{:ok,
%{
accounts: accounts_count,
network_sites: sites_count,
inventory_items: items_count
}}
else
{:error, reason} ->
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
{:error, reason}
end
end
@doc false
def calculate_total_mrr(accounts) do
Enum.reduce(accounts, 0.0, fn account, total ->
services = account["account_services"] || []
total + sum_service_prices(services)
end)
end
@doc false
def sum_service_prices(services) do
Enum.reduce(services, 0.0, fn service, acc -> add_price(acc, service["price_override"]) end)
end
@doc false
def add_price(acc, price) when is_number(price), do: acc + price
def add_price(acc, _price), do: acc
@doc false
def humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token"
def humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions"
def humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s"
def humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}"
def humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do
messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1)))
"GraphQL errors: #{messages}"
end
def humanize_sync_error(reason) when is_binary(reason) do
if ssl_failure?(reason) do
"SSL certificate verification failed — unable to establish a secure connection"
else
Logger.warning("Sync failed with raw error: #{reason}")
"An unexpected error occurred during sync"
end
end
def humanize_sync_error(reason) do
Logger.warning("Sonar sync failed with unexpected error: #{inspect(reason)}")
"An unexpected error occurred during sync"
end
defp ssl_failure?(reason), do: String.contains?(reason, ["CA trust store", "cacert", "certificate"])
end