add gaiia integration stages 1-2: sync engine and entity mapping UI

Stage 1 - Foundation:
- GraphQL client with Relay cursor pagination and rate limiting
- 4 Ecto schemas: accounts, network sites, inventory items, billing subscriptions
- Gaiia context with upsert operations preserving user mappings
- Sync module orchestrating full organization sync
- Oban cron worker running every 15 minutes
- Migration creating 4 tables with org-scoped unique constraints

Stage 2 - Entity Mapping:
- GaiiaMappingLive with two tabs (Network Sites / Inventory Items)
- Match suggestion engine (IP/name matching with confidence levels)
- Inline search and link/unlink UI following Preseem devices pattern
- Filter by all/mapped/unmapped with URL-driven state
- Amber suggestion badges for potential matches
This commit is contained in:
Graham McIntire 2026-02-13 10:31:32 -06:00
parent 21bbcccc30
commit 3cd2303b6f
No known key found for this signature in database
25 changed files with 2903 additions and 3 deletions

View file

@ -83,7 +83,9 @@ config :towerops, Oban,
# Sync Preseem data every 10 minutes
{"*/10 * * * *", Towerops.Workers.PreseemSyncWorker},
# Compute Preseem baselines and fleet profiles nightly at 2:30 AM
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker}
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker},
# Sync Gaiia data every 15 minutes
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker}
]},
# Automatically delete completed jobs after 60 seconds
{Oban.Plugins.Pruner, max_age: 60},

View file

@ -196,7 +196,9 @@ if config_env() == :prod do
# Sync Preseem data every 10 minutes
{"*/10 * * * *", Towerops.Workers.PreseemSyncWorker},
# Compute Preseem baselines and fleet profiles nightly at 2:30 AM
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker}
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker},
# Sync Gaiia data every 15 minutes
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker}
]},
# Automatically delete completed jobs after 60 seconds
{Oban.Plugins.Pruner, max_age: 60},

204
lib/towerops/gaiia.ex Normal file
View file

@ -0,0 +1,204 @@
defmodule Towerops.Gaiia do
@moduledoc """
Context for querying Gaiia integration data.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Gaiia.Account
alias Towerops.Gaiia.BillingSubscription
alias Towerops.Gaiia.InventoryItem
alias Towerops.Gaiia.NetworkSite
alias Towerops.Repo
alias Towerops.Sites.Site
# --- Accounts ---
def list_accounts(organization_id) do
Account
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
def get_account(organization_id, gaiia_id) do
Repo.get_by(Account, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_account(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%Account{}
|> Account.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
# --- Network Sites ---
def list_network_sites(organization_id) do
NetworkSite
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
def get_network_site(organization_id, gaiia_id) do
Repo.get_by(NetworkSite, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_network_site(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%NetworkSite{}
|> NetworkSite.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :site_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
def update_network_site_mapping(%NetworkSite{} = network_site, attrs) do
network_site
|> NetworkSite.changeset(attrs)
|> Repo.update()
end
# --- Inventory Items ---
def list_inventory_items(organization_id) do
InventoryItem
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
def get_inventory_item(organization_id, gaiia_id) do
Repo.get_by(InventoryItem, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_inventory_item(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%InventoryItem{}
|> InventoryItem.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :device_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
def update_inventory_item_mapping(%InventoryItem{} = inventory_item, attrs) do
inventory_item
|> InventoryItem.changeset(attrs)
|> Repo.update()
end
# --- Billing Subscriptions ---
def list_billing_subscriptions(organization_id) do
BillingSubscription
|> where(organization_id: ^organization_id)
|> order_by(:product_name)
|> Repo.all()
end
def list_billing_subscriptions_for_account(organization_id, account_gaiia_id) do
BillingSubscription
|> where(organization_id: ^organization_id, account_gaiia_id: ^account_gaiia_id)
|> order_by(:product_name)
|> Repo.all()
end
def get_billing_subscription(organization_id, gaiia_id) do
Repo.get_by(BillingSubscription, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_billing_subscription(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%BillingSubscription{}
|> BillingSubscription.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
# --- Match Suggestions ---
@doc """
Suggest Towerops Sites that might match a Gaiia Network Site.
Matches by case-insensitive name containment (bidirectional).
Returns a list of `%{entity: site, match_type: "name", confidence: :medium}`.
"""
def suggest_site_matches(_organization_id, %NetworkSite{name: nil}), do: []
def suggest_site_matches(_organization_id, %NetworkSite{name: ""}), do: []
def suggest_site_matches(organization_id, %NetworkSite{name: gaiia_name}) do
search_term = "%#{gaiia_name}%"
# Find sites where either name contains the other (bidirectional)
Site
|> where(organization_id: ^organization_id)
|> where([s], ilike(s.name, ^search_term) or ilike(^gaiia_name, fragment("'%' || ? || '%'", s.name)))
|> Repo.all()
|> Enum.map(fn site -> %{entity: site, match_type: "name", confidence: :medium} end)
end
@doc """
Suggest Towerops Devices that might match a Gaiia Inventory Item.
Matching strategies (in confidence order):
- IP address exact match (:high confidence)
- Name containment match (:low confidence)
Returns a list of `%{entity: device, match_type: type, confidence: level}`,
sorted by confidence (high first). Each device appears at most once per match type.
"""
def suggest_device_matches(organization_id, %InventoryItem{} = item) do
ip_matches = find_ip_matches(organization_id, item.ip_address)
name_matches = find_name_matches(organization_id, item.name)
(ip_matches ++ name_matches)
|> Enum.uniq_by(fn %{entity: d, match_type: t} -> {d.id, t} end)
|> Enum.sort_by(fn %{confidence: c} -> confidence_rank(c) end)
end
defp find_ip_matches(_organization_id, nil), do: []
defp find_ip_matches(_organization_id, ""), do: []
defp find_ip_matches(organization_id, ip_address) do
Device
|> where(organization_id: ^organization_id, ip_address: ^ip_address)
|> Repo.all()
|> Repo.preload(:site)
|> Enum.map(fn device -> %{entity: device, match_type: "ip", confidence: :high} end)
end
defp find_name_matches(_organization_id, nil), do: []
defp find_name_matches(_organization_id, ""), do: []
defp find_name_matches(organization_id, name) do
search_term = "%#{name}%"
Device
|> where(organization_id: ^organization_id)
|> where([d], ilike(d.name, ^search_term) or ilike(^name, fragment("'%' || ? || '%'", d.name)))
|> Repo.all()
|> Repo.preload(:site)
|> Enum.map(fn device -> %{entity: device, match_type: "name", confidence: :low} end)
end
defp confidence_rank(:high), do: 0
defp confidence_rank(:medium), do: 1
defp confidence_rank(:low), do: 2
end

View file

@ -0,0 +1,49 @@
defmodule Towerops.Gaiia.Account do
@moduledoc """
Schema for subscriber accounts synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "gaiia_accounts" do
field :gaiia_id, :string
field :readable_id, :string
field :name, :string
field :status, :string
field :account_type, :string
field :address, :map
field :subscription_count, :integer
field :mrr, :decimal
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
def changeset(account, attrs) do
account
|> cast(attrs, [
:organization_id,
:gaiia_id,
:readable_id,
:name,
:status,
:account_type,
:address,
:subscription_count,
:mrr,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -0,0 +1,49 @@
defmodule Towerops.Gaiia.BillingSubscription do
@moduledoc """
Schema for billing subscriptions synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "gaiia_billing_subscriptions" do
field :gaiia_id, :string
field :account_gaiia_id, :string
field :status, :string
field :product_name, :string
field :mrr_amount, :decimal
field :currency, :string
field :speed_download, :integer
field :speed_upload, :integer
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
def changeset(billing_subscription, attrs) do
billing_subscription
|> cast(attrs, [
:organization_id,
:gaiia_id,
:account_gaiia_id,
:status,
:product_name,
:mrr_amount,
:currency,
:speed_download,
:speed_upload,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -0,0 +1,267 @@
defmodule Towerops.Gaiia.Client do
@moduledoc """
GraphQL client for the Gaiia API.
Uses Req with built-in test support via `Req.Test`. Handles Relay-style
cursor pagination for all list endpoints.
"""
require Logger
@default_endpoint "https://api.gaiia.com/api/v1"
@accounts_query """
query ListAccounts($first: Int!, $after: String) {
accounts(first: $first, after: $after) {
edges {
node {
id
readableId
name
accountStatus
accountType
physicalAddress {
line1
city
state
zip
country
latitude
longitude
}
accountBillingSubscriptions(first: 100) {
edges {
node {
id
billingSubscriptionStatus
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@network_sites_query """
query ListNetworkSites($first: Int!, $after: String) {
networkSites(first: $first, after: $after) {
edges {
node {
id
name
address {
line1
city
state
zip
country
latitude
longitude
}
networkSiteIpBlocks(first: 100) {
edges {
node {
subnet
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@inventory_items_query """
query ListInventoryItems($first: Int!, $after: String) {
inventoryItems(first: $first, after: $after) {
edges {
node {
id
name
inventoryItemStatus
serialNumber
macAddress
ipAddress
inventoryModel {
name
manufacturer {
name
}
}
inventoryModelCategory {
name
}
account {
id
}
networkSite {
id
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@billing_subscriptions_query """
query ListBillingSubscriptions($first: Int!, $after: String) {
billingSubscriptions(first: $first, after: $after) {
edges {
node {
id
billingSubscriptionStatus
product {
name
}
recurringAmount
currency
account {
id
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@doc "Test the connection to Gaiia by running a simple viewer query."
def test_connection(api_key, opts \\ []) do
case query(api_key, "{ viewer { id } }", %{}, opts) do
{:ok, _data} -> {:ok, %{}}
{:error, reason} -> {:error, reason}
end
end
@doc "List all accounts with automatic pagination."
def list_accounts(api_key, opts \\ []) do
paginate(api_key, @accounts_query, "accounts", opts)
end
@doc "List all network sites with automatic pagination."
def list_network_sites(api_key, opts \\ []) do
paginate(api_key, @network_sites_query, "networkSites", opts)
end
@doc "List all inventory items with automatic pagination."
def list_inventory_items(api_key, opts \\ []) do
paginate(api_key, @inventory_items_query, "inventoryItems", opts)
end
@doc "List all billing subscriptions with automatic pagination."
def list_billing_subscriptions(api_key, opts \\ []) do
paginate(api_key, @billing_subscriptions_query, "billingSubscriptions", opts)
end
@doc "Execute a raw GraphQL query against the Gaiia API."
def query(api_key, query_string, variables \\ %{}, opts \\ []) do
case request(api_key, query_string, variables, opts) do
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
{:ok, data}
{:ok, %{status: status, body: %{"errors" => errors}}} when status in 200..299 ->
{:error, {:graphql_errors, errors}}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: 429, headers: headers}} ->
retry_after = get_retry_after(headers)
{:error, {:rate_limited, retry_after}}
{:ok, %{status: status, body: body}} ->
Logger.warning("Gaiia API unexpected status #{status}: #{inspect(body)}")
{:error, {:unexpected_status, status}}
{:error, reason} ->
Logger.error("Gaiia API connection error: #{inspect(reason)}")
{:error, reason}
end
end
defp paginate(api_key, query_string, root_key, opts, cursor \\ nil, acc \\ []) do
variables = %{"first" => 100, "after" => cursor}
case query(api_key, query_string, variables, opts) do
{:ok, data} ->
root = data[root_key]
nodes = Enum.map(root["edges"], & &1["node"])
all_nodes = acc ++ nodes
page_info = root["pageInfo"]
if page_info["hasNextPage"] do
paginate(api_key, query_string, root_key, opts, page_info["endCursor"], all_nodes)
else
{:ok, all_nodes}
end
{:error, reason} ->
{:error, reason}
end
end
defp request(api_key, query_string, variables, opts) do
endpoint = Keyword.get(opts, :endpoint, @default_endpoint)
req_opts = [
method: :post,
url: endpoint,
headers: [
{"authorization", "Bearer #{api_key}"},
{"content-type", "application/json"}
],
json: %{"query" => query_string, "variables" => variables}
]
req_opts =
if Application.get_env(:towerops, :env) == :test do
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
else
req_opts
end
Req.request(req_opts)
rescue
exception ->
{:error, Exception.message(exception)}
end
defp get_retry_after(headers) when is_map(headers) do
case Map.get(headers, "retry-after") do
[value | _] -> parse_retry_after(value)
_ -> 60
end
end
defp get_retry_after(_headers), do: 60
defp parse_retry_after(value) when is_binary(value) do
case Integer.parse(value) do
{seconds, _} -> seconds
:error -> 60
end
end
defp parse_retry_after(value) when is_integer(value), do: value
defp parse_retry_after(_), do: 60
end

View file

@ -0,0 +1,58 @@
defmodule Towerops.Gaiia.InventoryItem do
@moduledoc """
Schema for inventory items (deployed equipment) synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "gaiia_inventory_items" do
field :gaiia_id, :string
field :name, :string
field :status, :string
field :serial_number, :string
field :mac_address, :string
field :ip_address, :string
field :model_name, :string
field :manufacturer_name, :string
field :category, :string
field :assigned_account_gaiia_id, :string
field :assigned_network_site_gaiia_id, :string
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :device, Towerops.Devices.Device
timestamps(type: :utc_datetime)
end
def changeset(inventory_item, attrs) do
inventory_item
|> cast(attrs, [
:organization_id,
:gaiia_id,
:name,
:status,
:serial_number,
:mac_address,
:ip_address,
:model_name,
:manufacturer_name,
:category,
:assigned_account_gaiia_id,
:assigned_network_site_gaiia_id,
:device_id,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:device_id)
end
end

View file

@ -0,0 +1,48 @@
defmodule Towerops.Gaiia.NetworkSite do
@moduledoc """
Schema for network sites (towers/POPs) synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "gaiia_network_sites" do
field :gaiia_id, :string
field :name, :string
field :address, :map
field :ip_blocks, {:array, :string}, default: []
field :account_count, :integer
field :total_mrr, :decimal
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :site, Towerops.Sites.Site
timestamps(type: :utc_datetime)
end
def changeset(network_site, attrs) do
network_site
|> cast(attrs, [
:organization_id,
:gaiia_id,
:name,
:address,
:ip_blocks,
:account_count,
:total_mrr,
:site_id,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:site_id)
end
end

158
lib/towerops/gaiia/sync.ex Normal file
View file

@ -0,0 +1,158 @@
defmodule Towerops.Gaiia.Sync do
@moduledoc """
Orchestrates syncing data from the Gaiia GraphQL API into the local database.
Pulls accounts, network sites, inventory items, and billing subscriptions
from Gaiia, upserts them into local cache tables, and updates integration
sync status.
"""
alias Towerops.Gaiia
alias Towerops.Gaiia.Client
alias Towerops.Integrations
require Logger
@doc """
Main entry point: syncs all entity types for the given integration.
Returns `{:ok, %{accounts: n, network_sites: n, inventory_items: n, subscriptions: n}}`
or `{:error, reason}`.
"""
def sync_organization(%Integrations.Integration{} = integration) do
api_key = integration.credentials["api_key"]
org_id = integration.organization_id
opts = client_opts(integration)
with {:ok, accounts} <- Client.list_accounts(api_key, opts),
accounts_count = upsert_nodes(org_id, accounts, :account),
{:ok, sites} <- Client.list_network_sites(api_key, opts),
sites_count = upsert_nodes(org_id, sites, :network_site),
{:ok, items} <- Client.list_inventory_items(api_key, opts),
items_count = upsert_nodes(org_id, items, :inventory_item),
{:ok, subs} <- Client.list_billing_subscriptions(api_key, opts) do
subs_count = upsert_nodes(org_id, subs, :billing_subscription)
Integrations.update_sync_status(integration, "success")
{:ok,
%{
accounts: accounts_count,
network_sites: sites_count,
inventory_items: items_count,
subscriptions: subs_count
}}
else
{:error, reason} ->
Integrations.update_sync_status(integration, "failed")
{:error, reason}
end
end
defp upsert_nodes(org_id, nodes, type) do
Enum.reduce(nodes, 0, fn node, count ->
case upsert_node(org_id, node, type) do
{:ok, _} ->
count + 1
{:error, changeset} ->
Logger.warning("Failed to upsert Gaiia #{type}: #{inspect(changeset.errors)}")
count
end
end)
end
defp upsert_node(org_id, node, :account) do
Gaiia.upsert_account(org_id, map_account(node))
end
defp upsert_node(org_id, node, :network_site) do
Gaiia.upsert_network_site(org_id, map_network_site(node))
end
defp upsert_node(org_id, node, :inventory_item) do
Gaiia.upsert_inventory_item(org_id, map_inventory_item(node))
end
defp upsert_node(org_id, node, :billing_subscription) do
Gaiia.upsert_billing_subscription(org_id, map_billing_subscription(node))
end
defp map_account(node) do
subs = get_in(node, ["accountBillingSubscriptions", "edges"]) || []
active_count = Enum.count(subs, &(&1["node"]["billingSubscriptionStatus"] == "Active"))
%{
gaiia_id: node["id"],
readable_id: node["readableId"],
name: node["name"],
status: node["accountStatus"],
account_type: node["accountType"],
address: map_address(node["physicalAddress"]),
subscription_count: active_count,
raw_data: node
}
end
defp map_network_site(node) do
ip_block_edges = get_in(node, ["networkSiteIpBlocks", "edges"]) || []
ip_blocks = Enum.map(ip_block_edges, & &1["node"]["subnet"])
%{
gaiia_id: node["id"],
name: node["name"],
address: map_address(node["address"]),
ip_blocks: ip_blocks,
raw_data: node
}
end
defp map_inventory_item(node) do
%{
gaiia_id: node["id"],
name: node["name"],
status: node["inventoryItemStatus"],
serial_number: node["serialNumber"],
mac_address: node["macAddress"],
ip_address: node["ipAddress"],
model_name: get_in(node, ["inventoryModel", "name"]),
manufacturer_name: get_in(node, ["inventoryModel", "manufacturer", "name"]),
category: get_in(node, ["inventoryModelCategory", "name"]),
assigned_account_gaiia_id: get_in(node, ["account", "id"]),
assigned_network_site_gaiia_id: get_in(node, ["networkSite", "id"]),
raw_data: node
}
end
defp map_billing_subscription(node) do
%{
gaiia_id: node["id"],
account_gaiia_id: get_in(node, ["account", "id"]),
status: node["billingSubscriptionStatus"],
product_name: get_in(node, ["product", "name"]),
mrr_amount: node["recurringAmount"],
currency: node["currency"],
raw_data: node
}
end
defp map_address(nil), do: nil
defp map_address(addr) do
%{
"line1" => addr["line1"],
"city" => addr["city"],
"state" => addr["state"],
"zip" => addr["zip"],
"country" => addr["country"],
"latitude" => addr["latitude"],
"longitude" => addr["longitude"]
}
end
defp client_opts(integration) do
case integration.credentials["endpoint"] do
nil -> []
endpoint -> [endpoint: endpoint]
end
end
end

View file

@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_providers ~w(preseem)
@valid_providers ~w(preseem gaiia)
@valid_sync_statuses ~w(never success partial failed)
schema "integrations" do

View file

@ -26,6 +26,18 @@ defmodule Towerops.Sites do
)
end
@doc "Search sites by name for an organization. Returns up to 10 results."
def search_sites(organization_id, query) do
search_term = "%#{query}%"
Site
|> where(organization_id: ^organization_id)
|> where([s], ilike(s.name, ^search_term))
|> order_by(:name)
|> limit(10)
|> Repo.all()
end
@doc """
Returns the count of sites for an organization.
"""

View file

@ -0,0 +1,62 @@
defmodule Towerops.Workers.GaiiaSyncWorker do
@moduledoc """
Oban cron worker that syncs Gaiia data for all enabled integrations.
Runs every 15 minutes. For each org with an enabled Gaiia integration,
checks if enough time has elapsed since the last sync (based on the
integration's sync_interval_minutes), then calls Gaiia.Sync.sync_organization/1.
"""
use Oban.Worker, queue: :maintenance
alias Towerops.Gaiia.Sync
alias Towerops.Integrations
require Logger
@impl Oban.Worker
def perform(%Oban.Job{}) do
integrations = Integrations.list_enabled_integrations("gaiia")
results = Enum.map(integrations, &sync_integration/1)
synced = Enum.count(results, &match?({:ok, _}, &1))
failed = Enum.count(results, &match?({:error, _}, &1))
skipped = Enum.count(results, &(&1 == :skipped))
if synced > 0 or failed > 0 do
Logger.info("Gaiia sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped")
end
:ok
end
defp sync_integration(integration) do
if should_sync?(integration) do
case Sync.sync_organization(integration) do
{:ok, result} ->
Logger.info("Gaiia sync completed for org #{integration.organization_id}: #{inspect(result)}")
{:ok, result}
{:error, reason} ->
Logger.error("Gaiia sync failed for org #{integration.organization_id}: #{inspect(reason)}")
{:error, reason}
end
else
:skipped
end
end
defp should_sync?(integration) do
case integration.last_synced_at do
nil ->
true
last_synced_at ->
interval_seconds = (integration.sync_interval_minutes || 15) * 60
elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
elapsed >= interval_seconds
end
end
end

View file

@ -0,0 +1,241 @@
defmodule ToweropsWeb.Org.GaiiaMappingLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Repo
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
{:ok,
socket
|> assign(:organization, org)
|> assign(:tab, "sites")
|> assign(:filter, "all")
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()}
end
@impl true
def handle_params(params, _url, socket) do
tab =
case params["tab"] do
t when t in ~w(sites devices) -> t
_ -> "sites"
end
filter =
case params["filter"] do
f when f in ~w(all mapped unmapped) -> f
_ -> "all"
end
{:noreply,
socket
|> assign(:tab, tab)
|> assign(:filter, filter)
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()}
end
@impl true
def handle_event("start_link", %{"id" => id}, socket) do
{:noreply,
socket
|> assign(:linking_id, id)
|> assign(:search_query, "")
|> assign(:search_results, [])}
end
@impl true
def handle_event("cancel_link", _params, socket) do
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
org = socket.assigns.organization
results =
if String.length(query) >= 2 do
case socket.assigns.tab do
"sites" -> search_sites(org.id, query)
"devices" -> Devices.search_devices(org.id, query)
end
else
[]
end
{:noreply, socket |> assign(:search_query, query) |> assign(:search_results, results)}
end
@impl true
def handle_event("link_site", %{"gaiia-id" => gaiia_id, "site-id" => site_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_network_site(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia network site not found")}
network_site ->
case Gaiia.update_network_site_mapping(network_site, %{site_id: site_id}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()
|> put_flash(:info, "Site linked successfully")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to link site")}
end
end
end
@impl true
def handle_event("unlink_site", %{"gaiia-id" => gaiia_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_network_site(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia network site not found")}
network_site ->
case Gaiia.update_network_site_mapping(network_site, %{site_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_data()
|> put_flash(:info, "Site unlinked")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to unlink site")}
end
end
end
@impl true
def handle_event("link_device", %{"gaiia-id" => gaiia_id, "device-id" => device_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_inventory_item(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia inventory item not found")}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: device_id}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()
|> put_flash(:info, "Device linked successfully")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to link device")}
end
end
end
@impl true
def handle_event("unlink_device", %{"gaiia-id" => gaiia_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_inventory_item(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia inventory item not found")}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_data()
|> put_flash(:info, "Device unlinked")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to unlink device")}
end
end
end
defp load_data(socket) do
org_id = socket.assigns.organization.id
tab = socket.assigns.tab
filter = socket.assigns.filter
case tab do
"sites" -> load_network_sites(socket, org_id, filter)
"devices" -> load_inventory_items(socket, org_id, filter)
end
end
defp load_network_sites(socket, org_id, filter) do
sites =
org_id
|> Gaiia.list_network_sites()
|> Repo.preload(:site)
|> filter_by_mapping(filter)
suggestions =
Enum.reduce(sites, %{}, fn site, acc ->
matches = Gaiia.suggest_site_matches(org_id, site)
if matches == [], do: acc, else: Map.put(acc, site.gaiia_id, matches)
end)
socket
|> assign(:items, sites)
|> assign(:suggestions, suggestions)
end
defp load_inventory_items(socket, org_id, filter) do
items =
org_id
|> Gaiia.list_inventory_items()
|> Repo.preload(:device)
|> filter_by_mapping(filter)
suggestions =
Enum.reduce(items, %{}, fn item, acc ->
matches = Gaiia.suggest_device_matches(org_id, item)
if matches == [], do: acc, else: Map.put(acc, item.gaiia_id, matches)
end)
socket
|> assign(:items, items)
|> assign(:suggestions, suggestions)
end
defp filter_by_mapping(items, "mapped") do
Enum.filter(items, &mapped?/1)
end
defp filter_by_mapping(items, "unmapped") do
Enum.reject(items, &mapped?/1)
end
defp filter_by_mapping(items, _), do: items
defp mapped?(%Gaiia.NetworkSite{site_id: site_id}), do: not is_nil(site_id)
defp mapped?(%Gaiia.InventoryItem{device_id: device_id}), do: not is_nil(device_id)
defp search_sites(organization_id, query) do
Sites.search_sites(organization_id, query)
end
end

View file

@ -0,0 +1,357 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}/settings/integrations"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Integrations
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Gaiia Entity Mapping
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Link Gaiia entities to your Towerops sites and devices. Match suggestions are shown in amber.
</p>
</div>
<%!-- Tab navigation --%>
<div class="mt-6 border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8">
<.link
id="tab-sites"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=sites&filter=#{@filter}"
}
class={[
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
if(@tab == "sites",
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
else:
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
)
]}
>
Network Sites
</.link>
<.link
id="tab-devices"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=devices&filter=#{@filter}"
}
class={[
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
if(@tab == "devices",
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
else:
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
)
]}
>
Inventory Items
</.link>
</nav>
</div>
<%!-- Filter tabs --%>
<div class="mt-4 flex items-center gap-2">
<.link
:for={f <- ~w(all mapped unmapped)}
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=#{@tab}&filter=#{f}"
}
class={[
"rounded-full px-3 py-1 text-xs font-medium",
if(@filter == f,
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300",
else:
"bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-white/5 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
{String.capitalize(f)}
</.link>
</div>
<%!-- Content --%>
<div class="mt-6">
<%= if @items == [] do %>
<div
id="empty-state"
class="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-700"
>
<.icon
name={if(@tab == "sites", do: "hero-map-pin", else: "hero-cpu-chip")}
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
/>
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
<%= if @tab == "sites" do %>
No network sites found
<% else %>
No inventory items found
<% end %>
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= case @filter do %>
<% "mapped" -> %>
No mapped entities yet. Sync from Gaiia and link entities below.
<% "unmapped" -> %>
All entities are mapped.
<% _ -> %>
No Gaiia data has been synced yet. Enable the Gaiia integration and run a sync.
<% end %>
</p>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Name
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Gaiia ID
</th>
<%= if @tab == "devices" do %>
<th
scope="col"
class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 sm:table-cell"
>
IP Address
</th>
<% end %>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{if @tab == "sites", do: "Linked Site", else: "Linked Device"}
</th>
<th
scope="col"
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for item <- @items do %>
<tr id={"row-#{item.id}"} class="hover:bg-gray-50 dark:hover:bg-white/5">
<td class="whitespace-nowrap px-4 py-4 text-sm font-medium text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
{item.name || "Unnamed"}
<%= if Map.has_key?(@suggestions, item.gaiia_id) do %>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
<.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Suggestion
</span>
<% end %>
</div>
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
{item.gaiia_id}
</td>
<%= if @tab == "devices" do %>
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 sm:table-cell">
{item.ip_address || "-"}
</td>
<% end %>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= if @tab == "sites" and item.site do %>
<span class="font-medium text-gray-900 dark:text-white">
{item.site.name}
</span>
<% end %>
<%= if @tab == "devices" and item.device do %>
<.link
navigate={~p"/devices/#{item.device.id}"}
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{item.device.name || item.device.ip_address}
</.link>
<% end %>
<%= if (@tab == "sites" and is_nil(item.site_id)) or (@tab == "devices" and is_nil(item.device_id)) do %>
<span class="text-gray-400 dark:text-gray-500">-</span>
<% end %>
</td>
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
<%= if @tab == "sites" do %>
<%= if item.site_id do %>
<button
type="button"
id={"unlink-#{item.id}"}
phx-click="unlink_site"
phx-value-gaiia-id={item.gaiia_id}
data-confirm="Unlink this site?"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
Unlink
</button>
<% else %>
<button
type="button"
id={"link-#{item.id}"}
phx-click="start_link"
phx-value-id={item.gaiia_id}
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Link
</button>
<% end %>
<% else %>
<%= if item.device_id do %>
<button
type="button"
id={"unlink-#{item.id}"}
phx-click="unlink_device"
phx-value-gaiia-id={item.gaiia_id}
data-confirm="Unlink this device?"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
Unlink
</button>
<% else %>
<button
type="button"
id={"link-#{item.id}"}
phx-click="start_link"
phx-value-id={item.gaiia_id}
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Link
</button>
<% end %>
<% end %>
</td>
</tr>
<%!-- Inline linking row --%>
<%= if item.gaiia_id == @linking_id do %>
<tr id={"linking-row-#{item.id}"} class="bg-indigo-50 dark:bg-indigo-900/20">
<td colspan={if(@tab == "devices", do: "5", else: "4")} class="px-4 py-4">
<div class="flex items-center gap-4">
<div class="flex-1">
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
<%= if @tab == "sites" do %>
Search for a site to link to "{item.name}"
<% else %>
Search for a device to link to "{item.name}"
<% end %>
</p>
<%!-- Show suggestions if available --%>
<%= if suggestions = @suggestions[item.gaiia_id] do %>
<div class="mb-3">
<p class="mb-1 text-xs font-medium text-amber-700 dark:text-amber-400">
Suggested matches:
</p>
<div class="flex flex-wrap gap-2">
<%= for suggestion <- suggestions do %>
<button
type="button"
phx-click={
if(@tab == "sites", do: "link_site", else: "link_device")
}
phx-value-gaiia-id={item.gaiia_id}
phx-value-site-id={
if(@tab == "sites", do: suggestion.entity.id)
}
phx-value-device-id={
if(@tab == "devices", do: suggestion.entity.id)
}
class="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300 dark:hover:bg-amber-900/40"
>
{suggestion.entity.name}
<span class="rounded bg-amber-200 px-1 text-amber-700 dark:bg-amber-800 dark:text-amber-300">
{suggestion.match_type}
</span>
</button>
<% end %>
</div>
</div>
<% end %>
<form id="search-form" phx-change="search" class="flex gap-2">
<input
type="text"
id="search-input"
name="query"
value={@search_query}
placeholder={
if(@tab == "sites",
do: "Search by site name...",
else: "Search by device name or IP..."
)
}
autocomplete="off"
phx-debounce="300"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</form>
<%= if @search_results != [] do %>
<div class="mt-2 max-h-48 overflow-y-auto rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800">
<ul class="divide-y divide-gray-200 dark:divide-white/5">
<li :for={result <- @search_results}>
<button
type="button"
id={"select-#{result.id}"}
phx-click={
if(@tab == "sites", do: "link_site", else: "link_device")
}
phx-value-gaiia-id={item.gaiia_id}
phx-value-site-id={if(@tab == "sites", do: result.id)}
phx-value-device-id={if(@tab == "devices", do: result.id)}
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-white/5"
>
<span class="font-medium text-gray-900 dark:text-white">
{result.name || "Unnamed"}
</span>
<%= if @tab == "devices" do %>
<span class="text-gray-500 dark:text-gray-400">
{Map.get(result, :ip_address, "")}
<%= if site = Map.get(result, :site) do %>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
{site.name}
</span>
<% end %>
</span>
<% end %>
</button>
</li>
</ul>
</div>
<% end %>
<%= if @search_query != "" and String.length(@search_query) >= 2 and @search_results == [] do %>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
No results found matching "{@search_query}"
</p>
<% end %>
</div>
<button
type="button"
id="cancel-link"
phx-click="cancel_link"
class="self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Cancel
</button>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -312,6 +312,7 @@ defmodule ToweropsWeb.Router do
live "/settings/integrations", Org.IntegrationsLive, :index
live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index
live "/settings/integrations/preseem/insights", Org.PreseemInsightsLive, :index
live "/settings/integrations/gaiia/mapping", Org.GaiiaMappingLive, :index
end
end

View file

@ -0,0 +1,99 @@
defmodule Towerops.Repo.Migrations.CreateGaiiaTables do
use Ecto.Migration
def change do
create table(:gaiia_accounts, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :gaiia_id, :string, null: false
add :readable_id, :string
add :name, :string
add :status, :string
add :account_type, :string
add :address, :map
add :subscription_count, :integer
add :mrr, :decimal
add :raw_data, :map
timestamps(type: :utc_datetime)
end
create unique_index(:gaiia_accounts, [:organization_id, :gaiia_id])
create index(:gaiia_accounts, [:organization_id])
create table(:gaiia_network_sites, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :gaiia_id, :string, null: false
add :name, :string
add :address, :map
add :ip_blocks, {:array, :string}, default: []
add :account_count, :integer
add :total_mrr, :decimal
add :site_id, references(:sites, type: :binary_id, on_delete: :nilify_all)
add :raw_data, :map
timestamps(type: :utc_datetime)
end
create unique_index(:gaiia_network_sites, [:organization_id, :gaiia_id])
create index(:gaiia_network_sites, [:organization_id])
create index(:gaiia_network_sites, [:site_id])
create table(:gaiia_inventory_items, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :gaiia_id, :string, null: false
add :name, :string
add :status, :string
add :serial_number, :string
add :mac_address, :string
add :ip_address, :string
add :model_name, :string
add :manufacturer_name, :string
add :category, :string
add :assigned_account_gaiia_id, :string
add :assigned_network_site_gaiia_id, :string
add :device_id, references(:devices, type: :binary_id, on_delete: :nilify_all)
add :raw_data, :map
timestamps(type: :utc_datetime)
end
create unique_index(:gaiia_inventory_items, [:organization_id, :gaiia_id])
create index(:gaiia_inventory_items, [:organization_id])
create index(:gaiia_inventory_items, [:device_id])
create table(:gaiia_billing_subscriptions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :gaiia_id, :string, null: false
add :account_gaiia_id, :string
add :status, :string
add :product_name, :string
add :mrr_amount, :decimal
add :currency, :string
add :speed_download, :integer
add :speed_upload, :integer
add :raw_data, :map
timestamps(type: :utc_datetime)
end
create unique_index(:gaiia_billing_subscriptions, [:organization_id, :gaiia_id])
create index(:gaiia_billing_subscriptions, [:organization_id])
create index(:gaiia_billing_subscriptions, [:account_gaiia_id])
end
end

View file

@ -0,0 +1,64 @@
defmodule Towerops.Gaiia.AccountTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia.Account
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "changeset/2" do
test "valid with required fields", %{org: org} do
changeset =
Account.changeset(%Account{}, %{
organization_id: org.id,
gaiia_id: "acct-123"
})
assert changeset.valid?
end
test "invalid without gaiia_id", %{org: org} do
changeset =
Account.changeset(%Account{}, %{
organization_id: org.id
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).gaiia_id
end
test "invalid without organization_id" do
changeset =
Account.changeset(%Account{}, %{
gaiia_id: "acct-123"
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "accepts all optional fields", %{org: org} do
changeset =
Account.changeset(%Account{}, %{
organization_id: org.id,
gaiia_id: "acct-123",
readable_id: "1001",
name: "John Doe",
status: "active",
account_type: "residential",
address: %{"city" => "Portland"},
subscription_count: 2,
mrr: Decimal.new("79.99"),
raw_data: %{"extra" => "data"}
})
assert changeset.valid?
end
end
end

View file

@ -0,0 +1,54 @@
defmodule Towerops.Gaiia.BillingSubscriptionTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia.BillingSubscription
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "changeset/2" do
test "valid with required fields", %{org: org} do
changeset =
BillingSubscription.changeset(%BillingSubscription{}, %{
organization_id: org.id,
gaiia_id: "sub-123"
})
assert changeset.valid?
end
test "invalid without gaiia_id", %{org: org} do
changeset =
BillingSubscription.changeset(%BillingSubscription{}, %{
organization_id: org.id
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).gaiia_id
end
test "accepts all optional fields", %{org: org} do
changeset =
BillingSubscription.changeset(%BillingSubscription{}, %{
organization_id: org.id,
gaiia_id: "sub-123",
account_gaiia_id: "acct-456",
status: "active",
product_name: "100Mbps Unlimited",
mrr_amount: Decimal.new("79.99"),
currency: "USD",
speed_download: 100,
speed_upload: 25,
raw_data: %{"extra" => "data"}
})
assert changeset.valid?
end
end
end

View file

@ -0,0 +1,190 @@
defmodule Towerops.Gaiia.ClientTest do
use Towerops.DataCase, async: true
alias Towerops.Gaiia.Client
describe "test_connection/2" do
test "returns ok on successful connection" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{"data" => %{"viewer" => %{"id" => "user-1"}}})
end)
assert {:ok, %{}} = Client.test_connection("test-key")
end
test "returns error on unauthorized" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
assert {:error, :unauthorized} = Client.test_connection("bad-key")
end
test "returns error on forbidden" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(403)
|> Req.Test.json(%{"error" => "forbidden"})
end)
assert {:error, :forbidden} = Client.test_connection("bad-key")
end
end
describe "list_accounts/2" do
test "returns accounts from single page" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"data" => %{
"accounts" => %{
"edges" => [
%{"node" => %{"id" => "acct-1", "name" => "Alice"}},
%{"node" => %{"id" => "acct-2", "name" => "Bob"}}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
})
end)
assert {:ok, accounts} = Client.list_accounts("test-key")
assert length(accounts) == 2
assert Enum.map(accounts, & &1["id"]) == ["acct-1", "acct-2"]
end
test "handles paginated results" do
call_count = :counters.new(1, [:atomics])
Req.Test.stub(Client, fn conn ->
:counters.add(call_count, 1, 1)
count = :counters.get(call_count, 1)
if count == 1 do
Req.Test.json(conn, %{
"data" => %{
"accounts" => %{
"edges" => [%{"node" => %{"id" => "acct-1", "name" => "Alice"}}],
"pageInfo" => %{"hasNextPage" => true, "endCursor" => "cursor-1"}
}
}
})
else
Req.Test.json(conn, %{
"data" => %{
"accounts" => %{
"edges" => [%{"node" => %{"id" => "acct-2", "name" => "Bob"}}],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
})
end
end)
assert {:ok, accounts} = Client.list_accounts("test-key")
assert length(accounts) == 2
end
test "returns error on API failure" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
assert {:error, :unauthorized} = Client.list_accounts("bad-key")
end
end
describe "list_network_sites/2" do
test "returns network sites" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"data" => %{
"networkSites" => %{
"edges" => [
%{"node" => %{"id" => "site-1", "name" => "Tower 1"}}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
})
end)
assert {:ok, sites} = Client.list_network_sites("test-key")
assert length(sites) == 1
assert hd(sites)["name"] == "Tower 1"
end
end
describe "list_inventory_items/2" do
test "returns inventory items" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"data" => %{
"inventoryItems" => %{
"edges" => [
%{"node" => %{"id" => "item-1", "name" => "AF5XHD"}}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
})
end)
assert {:ok, items} = Client.list_inventory_items("test-key")
assert length(items) == 1
assert hd(items)["name"] == "AF5XHD"
end
end
describe "list_billing_subscriptions/2" do
test "returns billing subscriptions" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"data" => %{
"billingSubscriptions" => %{
"edges" => [
%{
"node" => %{
"id" => "sub-1",
"billingSubscriptionStatus" => "Active",
"product" => %{"name" => "100Mbps"}
}
}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
})
end)
assert {:ok, subs} = Client.list_billing_subscriptions("test-key")
assert length(subs) == 1
end
end
describe "query/4" do
test "handles GraphQL errors" do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"errors" => [%{"message" => "Field not found"}]
})
end)
assert {:error, {:graphql_errors, _}} = Client.query("test-key", "{ invalid }")
end
test "handles rate limiting" do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_resp_header("retry-after", "30")
|> Plug.Conn.put_status(429)
|> Req.Test.json(%{"error" => "rate limited"})
end)
assert {:error, {:rate_limited, 30}} = Client.query("test-key", "{ viewer { id } }")
end
end
end

View file

@ -0,0 +1,57 @@
defmodule Towerops.Gaiia.InventoryItemTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia.InventoryItem
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "changeset/2" do
test "valid with required fields", %{org: org} do
changeset =
InventoryItem.changeset(%InventoryItem{}, %{
organization_id: org.id,
gaiia_id: "item-123"
})
assert changeset.valid?
end
test "invalid without gaiia_id", %{org: org} do
changeset =
InventoryItem.changeset(%InventoryItem{}, %{
organization_id: org.id
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).gaiia_id
end
test "accepts all optional fields", %{org: org} do
changeset =
InventoryItem.changeset(%InventoryItem{}, %{
organization_id: org.id,
gaiia_id: "item-123",
name: "AF5XHD",
status: "active",
serial_number: "SN12345",
mac_address: "AA:BB:CC:DD:EE:FF",
ip_address: "10.0.0.1",
model_name: "airFiber 5XHD",
manufacturer_name: "Ubiquiti",
category: "AP",
assigned_account_gaiia_id: "acct-456",
assigned_network_site_gaiia_id: "site-789",
raw_data: %{"extra" => "data"}
})
assert changeset.valid?
end
end
end

View file

@ -0,0 +1,395 @@
defmodule Towerops.Gaiia.MatchSuggestionsTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia
alias Towerops.Sites
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org, user: user}
end
describe "suggest_site_matches/2" do
test "returns empty list when no sites exist", %{org: org} do
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower Alpha"
})
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
end
test "returns empty list when no names match", %{org: org} do
{:ok, _site} =
Sites.create_site(%{
name: "Completely Different",
organization_id: org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower Alpha"
})
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
end
test "matches site by exact name case-insensitively", %{org: org} do
{:ok, site} =
Sites.create_site(%{
name: "Tower Alpha",
organization_id: org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "tower alpha"
})
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
assert length(results) == 1
assert [%{entity: matched_site, match_type: "name", confidence: _}] = results
assert matched_site.id == site.id
end
test "matches site by partial name (contains)", %{org: org} do
{:ok, site} =
Sites.create_site(%{
name: "North Tower Alpha Sector",
organization_id: org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower Alpha"
})
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
assert length(results) == 1
assert [%{entity: matched_site, match_type: "name"}] = results
assert matched_site.id == site.id
end
test "matches when towerops site name is substring of gaiia name", %{org: org} do
{:ok, site} =
Sites.create_site(%{
name: "Alpha",
organization_id: org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower Alpha Sector"
})
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
assert length(results) == 1
assert [%{entity: matched_site, match_type: "name"}] = results
assert matched_site.id == site.id
end
test "returns multiple matching sites", %{org: org} do
{:ok, site1} =
Sites.create_site(%{
name: "Tower Alpha",
organization_id: org.id
})
{:ok, site2} =
Sites.create_site(%{
name: "Tower Alpha East",
organization_id: org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower Alpha"
})
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
assert length(results) == 2
matched_ids = Enum.map(results, fn %{entity: s} -> s.id end)
assert site1.id in matched_ids
assert site2.id in matched_ids
end
test "does not match sites from other organizations", %{org: org, user: _user} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
{:ok, _other_site} =
Sites.create_site(%{
name: "Tower Alpha",
organization_id: other_org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower Alpha"
})
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
end
test "does not match when gaiia site has no name", %{org: org} do
{:ok, _site} =
Sites.create_site(%{
name: "Tower Alpha",
organization_id: org.id
})
{:ok, gaiia_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: nil
})
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
end
end
describe "suggest_device_matches/2" do
test "returns empty list when no devices exist", %{org: org} do
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
end
test "returns empty list when nothing matches", %{org: org} do
_device =
device_fixture(%{
organization_id: org.id,
name: "Switch Bravo",
ip_address: "192.168.1.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
end
test "matches device by exact IP address", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Switch One",
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Totally Different Name",
ip_address: "10.0.0.1"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 1
assert [%{entity: matched, match_type: "ip", confidence: confidence}] = results
assert matched.id == device.id
assert confidence in [:high, :medium]
end
test "matches device by name case-insensitively", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
ip_address: "192.168.1.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "router alpha",
ip_address: "10.99.99.99"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 1
assert [%{entity: matched, match_type: "name", confidence: :low}] = results
assert matched.id == device.id
end
test "matches device by partial name (contains)", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Core Router Alpha Main",
ip_address: "192.168.1.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.99.99.99"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 1
assert [%{entity: matched, match_type: "name"}] = results
assert matched.id == device.id
end
test "returns multiple match types for same device sorted by confidence", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) >= 2
# All matches should reference the same device
matched_ids = results |> Enum.map(fn %{entity: d} -> d.id end) |> Enum.uniq()
assert matched_ids == [device.id]
# IP match should come before name match (higher confidence first)
match_types = Enum.map(results, fn %{match_type: t} -> t end)
ip_index = Enum.find_index(match_types, &(&1 == "ip"))
name_index = Enum.find_index(match_types, &(&1 == "name"))
assert ip_index < name_index
end
test "does not match devices from other organizations", %{org: org} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
_other_device =
device_fixture(%{
organization_id: other_org.id,
name: "Router Alpha",
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
end
test "does not match when gaiia item has neither IP nor name", %{org: org} do
_device =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1"
})
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
end
test "IP match has higher confidence than name match", %{org: org} do
device_ip =
device_fixture(%{
organization_id: org.id,
name: "Unrelated Name",
ip_address: "10.0.0.1"
})
device_name =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
ip_address: "192.168.99.99"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 2
# IP match should appear first (higher confidence)
assert [first | _rest] = results
assert first.entity.id == device_ip.id
assert first.match_type == "ip"
# Name match should be last
name_match = Enum.find(results, fn %{match_type: t} -> t == "name" end)
assert name_match.entity.id == device_name.id
assert name_match.confidence == :low
end
test "does not produce duplicate matches for same device and match type", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
# Each match type should appear at most once per device
type_device_pairs =
Enum.map(results, fn %{entity: d, match_type: t} -> {d.id, t} end)
assert type_device_pairs == Enum.uniq(type_device_pairs)
# Verify the device is present
assert Enum.any?(results, fn %{entity: d} -> d.id == device.id end)
end
end
end

View file

@ -0,0 +1,63 @@
defmodule Towerops.Gaiia.NetworkSiteTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia.NetworkSite
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "changeset/2" do
test "valid with required fields", %{org: org} do
changeset =
NetworkSite.changeset(%NetworkSite{}, %{
organization_id: org.id,
gaiia_id: "site-123"
})
assert changeset.valid?
end
test "invalid without gaiia_id", %{org: org} do
changeset =
NetworkSite.changeset(%NetworkSite{}, %{
organization_id: org.id
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).gaiia_id
end
test "accepts ip_blocks array", %{org: org} do
changeset =
NetworkSite.changeset(%NetworkSite{}, %{
organization_id: org.id,
gaiia_id: "site-123",
ip_blocks: ["10.0.0.0/24", "192.168.1.0/24"]
})
assert changeset.valid?
end
test "accepts all optional fields", %{org: org} do
changeset =
NetworkSite.changeset(%NetworkSite{}, %{
organization_id: org.id,
gaiia_id: "site-123",
name: "Tower 3",
address: %{"city" => "Portland"},
ip_blocks: ["10.0.0.0/24"],
account_count: 147,
total_mrr: Decimal.new("17640.00"),
raw_data: %{"extra" => "data"}
})
assert changeset.valid?
end
end
end

View file

@ -0,0 +1,254 @@
defmodule Towerops.Gaiia.SyncTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.IntegrationsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia
alias Towerops.Gaiia.Client
alias Towerops.Gaiia.Sync
setup do
user = user_fixture()
org = organization_fixture(user.id)
integration = integration_fixture(org.id, %{provider: "gaiia"})
%{org: org, integration: integration}
end
describe "sync_organization/1" do
test "syncs all entity types", %{integration: integration} do
stub_all_endpoints()
assert {:ok, result} = Sync.sync_organization(integration)
assert result.accounts == 1
assert result.network_sites == 1
assert result.inventory_items == 1
assert result.subscriptions == 1
updated = Repo.reload!(integration)
assert updated.last_sync_status == "success"
assert updated.last_synced_at
end
test "maps account fields correctly", %{org: org, integration: integration} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
[account] = Gaiia.list_accounts(org.id)
assert account.gaiia_id == "acct-1"
assert account.readable_id == "1001"
assert account.name == "Alice Smith"
assert account.status == "Active"
assert account.account_type == "Residential"
assert account.subscription_count == 1
assert account.address["city"] == "Portland"
end
test "maps network site fields correctly", %{org: org, integration: integration} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
[site] = Gaiia.list_network_sites(org.id)
assert site.gaiia_id == "site-1"
assert site.name == "Tower 3"
assert site.ip_blocks == ["10.0.0.0/24"]
end
test "maps inventory item fields correctly", %{org: org, integration: integration} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
[item] = Gaiia.list_inventory_items(org.id)
assert item.gaiia_id == "item-1"
assert item.name == "AF5XHD"
assert item.serial_number == "SN12345"
assert item.mac_address == "AA:BB:CC:DD:EE:FF"
assert item.model_name == "airFiber 5XHD"
assert item.manufacturer_name == "Ubiquiti"
assert item.category == "AP"
end
test "maps billing subscription fields correctly", %{org: org, integration: integration} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
[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.product_name == "100Mbps Unlimited"
end
test "marks sync as failed on API error", %{integration: integration} do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{"error" => "unauthorized"})
end)
assert {:error, :unauthorized} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
assert updated.last_sync_status == "failed"
end
test "upserts existing records without duplicating", %{org: org, integration: integration} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
{:ok, _} = Sync.sync_organization(Repo.reload!(integration))
assert length(Gaiia.list_accounts(org.id)) == 1
assert length(Gaiia.list_network_sites(org.id)) == 1
assert length(Gaiia.list_inventory_items(org.id)) == 1
assert length(Gaiia.list_billing_subscriptions(org.id)) == 1
end
end
defp stub_all_endpoints do
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
query = decoded["query"]
response =
cond do
String.contains?(query, "ListAccounts") -> accounts_response()
String.contains?(query, "ListNetworkSites") -> network_sites_response()
String.contains?(query, "ListInventoryItems") -> inventory_items_response()
String.contains?(query, "ListBillingSubscriptions") -> billing_subscriptions_response()
true -> %{"data" => %{}}
end
Req.Test.json(conn, response)
end)
end
defp accounts_response do
%{
"data" => %{
"accounts" => %{
"edges" => [
%{
"node" => %{
"id" => "acct-1",
"readableId" => "1001",
"name" => "Alice Smith",
"accountStatus" => "Active",
"accountType" => "Residential",
"physicalAddress" => %{
"line1" => "123 Main St",
"city" => "Portland",
"state" => "OR",
"zip" => "97201",
"country" => "US",
"latitude" => 45.5,
"longitude" => -122.6
},
"accountBillingSubscriptions" => %{
"edges" => [
%{
"node" => %{
"id" => "sub-1",
"billingSubscriptionStatus" => "Active"
}
}
]
}
}
}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
defp network_sites_response do
%{
"data" => %{
"networkSites" => %{
"edges" => [
%{
"node" => %{
"id" => "site-1",
"name" => "Tower 3",
"address" => %{
"line1" => "456 Tower Rd",
"city" => "Portland",
"state" => "OR",
"zip" => "97201",
"country" => "US",
"latitude" => 45.5,
"longitude" => -122.6
},
"networkSiteIpBlocks" => %{
"edges" => [
%{"node" => %{"subnet" => "10.0.0.0/24"}}
]
}
}
}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
defp inventory_items_response do
%{
"data" => %{
"inventoryItems" => %{
"edges" => [
%{
"node" => %{
"id" => "item-1",
"name" => "AF5XHD",
"inventoryItemStatus" => "Active",
"serialNumber" => "SN12345",
"macAddress" => "AA:BB:CC:DD:EE:FF",
"ipAddress" => "10.0.0.1",
"inventoryModel" => %{
"name" => "airFiber 5XHD",
"manufacturer" => %{"name" => "Ubiquiti"}
},
"inventoryModelCategory" => %{"name" => "AP"},
"account" => %{"id" => "acct-1"},
"networkSite" => %{"id" => "site-1"}
}
}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
defp billing_subscriptions_response do
%{
"data" => %{
"billingSubscriptions" => %{
"edges" => [
%{
"node" => %{
"id" => "sub-1",
"billingSubscriptionStatus" => "Active",
"product" => %{"name" => "100Mbps Unlimited"},
"recurringAmount" => "79.99",
"currency" => "USD",
"account" => %{"id" => "acct-1"}
}
}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
end

View file

@ -0,0 +1,128 @@
defmodule Towerops.GaiiaTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "accounts" do
test "upsert_account/2 creates a new account", %{org: org} do
attrs = %{gaiia_id: "acct-1", name: "Alice", status: "Active"}
assert {:ok, account} = Gaiia.upsert_account(org.id, attrs)
assert account.gaiia_id == "acct-1"
assert account.name == "Alice"
end
test "upsert_account/2 updates existing account", %{org: org} do
attrs = %{gaiia_id: "acct-1", name: "Alice"}
{:ok, _} = Gaiia.upsert_account(org.id, attrs)
{:ok, updated} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-1", name: "Alice Smith"})
assert updated.name == "Alice Smith"
assert length(Gaiia.list_accounts(org.id)) == 1
end
test "list_accounts/1 returns accounts for organization", %{org: org} do
{:ok, _} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-1", name: "Alice"})
{:ok, _} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-2", name: "Bob"})
assert length(Gaiia.list_accounts(org.id)) == 2
end
test "get_account/2 returns account by gaiia_id", %{org: org} do
{:ok, _} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-1", name: "Alice"})
assert %{name: "Alice"} = Gaiia.get_account(org.id, "acct-1")
assert is_nil(Gaiia.get_account(org.id, "nonexistent"))
end
end
describe "network_sites" do
test "upsert_network_site/2 creates a new site", %{org: org} do
attrs = %{gaiia_id: "site-1", name: "Tower 3", ip_blocks: ["10.0.0.0/24"]}
assert {:ok, site} = Gaiia.upsert_network_site(org.id, attrs)
assert site.gaiia_id == "site-1"
assert site.ip_blocks == ["10.0.0.0/24"]
end
test "upsert_network_site/2 preserves site_id mapping on update", %{org: org} do
{:ok, site} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 3"})
assert is_nil(site.site_id)
# Simulate user mapping
towerops_site = create_site(org.id)
Gaiia.update_network_site_mapping(site, %{site_id: towerops_site.id})
# Re-sync should preserve mapping
{:ok, updated} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 3 Updated"})
assert updated.name == "Tower 3 Updated"
assert updated.site_id == towerops_site.id
end
test "list_network_sites/1 returns sites for organization", %{org: org} do
{:ok, _} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 1"})
assert length(Gaiia.list_network_sites(org.id)) == 1
end
end
describe "inventory_items" do
test "upsert_inventory_item/2 creates a new item", %{org: org} do
attrs = %{gaiia_id: "item-1", name: "AF5XHD", serial_number: "SN123"}
assert {:ok, item} = Gaiia.upsert_inventory_item(org.id, attrs)
assert item.serial_number == "SN123"
end
test "upsert_inventory_item/2 preserves device_id mapping on update", %{org: org} do
{:ok, item} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD"})
assert is_nil(item.device_id)
device = device_fixture(%{organization_id: org.id})
Gaiia.update_inventory_item_mapping(item, %{device_id: device.id})
{:ok, updated} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD v2"})
assert updated.name == "AF5XHD v2"
assert updated.device_id == device.id
end
test "list_inventory_items/1 returns items for organization", %{org: org} do
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD"})
assert length(Gaiia.list_inventory_items(org.id)) == 1
end
end
describe "billing_subscriptions" do
test "upsert_billing_subscription/2 creates a new subscription", %{org: org} do
attrs = %{gaiia_id: "sub-1", account_gaiia_id: "acct-1", product_name: "100Mbps"}
assert {:ok, sub} = Gaiia.upsert_billing_subscription(org.id, attrs)
assert sub.product_name == "100Mbps"
end
test "list_billing_subscriptions_for_account/2 filters by account", %{org: org} do
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-1", account_gaiia_id: "acct-1"})
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-2", account_gaiia_id: "acct-2"})
assert length(Gaiia.list_billing_subscriptions_for_account(org.id, "acct-1")) == 1
end
test "list_billing_subscriptions/1 returns all subscriptions", %{org: org} do
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-1"})
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-2"})
assert length(Gaiia.list_billing_subscriptions(org.id)) == 2
end
end
defp create_site(organization_id) do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site #{System.unique_integer([:positive])}",
organization_id: organization_id
})
site
end
end

View file

@ -0,0 +1,86 @@
defmodule Towerops.Workers.GaiiaSyncWorkerTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.IntegrationsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia.Client
alias Towerops.Workers.GaiiaSyncWorker
setup do
user = user_fixture()
org = organization_fixture(user.id)
integration = integration_fixture(org.id, %{provider: "gaiia"})
%{org: org, integration: integration}
end
describe "perform/1" do
test "syncs enabled gaiia integrations", %{org: org} do
stub_all_endpoints()
assert :ok = GaiiaSyncWorker.perform(%Oban.Job{})
# Verify data was synced
assert length(Towerops.Gaiia.list_accounts(org.id)) == 1
end
test "skips recently synced integrations", %{integration: integration} do
# Mark as recently synced
Towerops.Integrations.update_sync_status(integration, "success")
# Should skip since it was just synced
assert :ok = GaiiaSyncWorker.perform(%Oban.Job{})
end
end
defp stub_all_endpoints do
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
query = decoded["query"]
response =
cond do
String.contains?(query, "ListAccounts") -> accounts_response()
String.contains?(query, "ListNetworkSites") -> empty_response("networkSites")
String.contains?(query, "ListInventoryItems") -> empty_response("inventoryItems")
String.contains?(query, "ListBillingSubscriptions") -> empty_response("billingSubscriptions")
true -> %{"data" => %{}}
end
Req.Test.json(conn, response)
end)
end
defp accounts_response do
%{
"data" => %{
"accounts" => %{
"edges" => [
%{
"node" => %{
"id" => "acct-1",
"name" => "Test Account",
"accountStatus" => "Active",
"accountBillingSubscriptions" => %{"edges" => []}
}
}
],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
defp empty_response(root_key) do
%{
"data" => %{
root_key => %{
"edges" => [],
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
end