From 3cd2303b6fb13d540486bda358c25bf58ecb37be Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 10:31:32 -0600 Subject: [PATCH] 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 --- config/dev.exs | 4 +- config/runtime.exs | 4 +- lib/towerops/gaiia.ex | 204 +++++++++ lib/towerops/gaiia/account.ex | 49 +++ lib/towerops/gaiia/billing_subscription.ex | 49 +++ lib/towerops/gaiia/client.ex | 267 ++++++++++++ lib/towerops/gaiia/inventory_item.ex | 58 +++ lib/towerops/gaiia/network_site.ex | 48 +++ lib/towerops/gaiia/sync.ex | 158 +++++++ lib/towerops/integrations/integration.ex | 2 +- lib/towerops/sites.ex | 12 + lib/towerops/workers/gaiia_sync_worker.ex | 62 +++ .../live/org/gaiia_mapping_live.ex | 241 +++++++++++ .../live/org/gaiia_mapping_live.html.heex | 357 ++++++++++++++++ lib/towerops_web/router.ex | 1 + .../20260213160011_create_gaiia_tables.exs | 99 +++++ test/towerops/gaiia/account_test.exs | 64 +++ .../gaiia/billing_subscription_test.exs | 54 +++ test/towerops/gaiia/client_test.exs | 190 +++++++++ test/towerops/gaiia/inventory_item_test.exs | 57 +++ .../towerops/gaiia/match_suggestions_test.exs | 395 ++++++++++++++++++ test/towerops/gaiia/network_site_test.exs | 63 +++ test/towerops/gaiia/sync_test.exs | 254 +++++++++++ test/towerops/gaiia_test.exs | 128 ++++++ .../workers/gaiia_sync_worker_test.exs | 86 ++++ 25 files changed, 2903 insertions(+), 3 deletions(-) create mode 100644 lib/towerops/gaiia.ex create mode 100644 lib/towerops/gaiia/account.ex create mode 100644 lib/towerops/gaiia/billing_subscription.ex create mode 100644 lib/towerops/gaiia/client.ex create mode 100644 lib/towerops/gaiia/inventory_item.ex create mode 100644 lib/towerops/gaiia/network_site.ex create mode 100644 lib/towerops/gaiia/sync.ex create mode 100644 lib/towerops/workers/gaiia_sync_worker.ex create mode 100644 lib/towerops_web/live/org/gaiia_mapping_live.ex create mode 100644 lib/towerops_web/live/org/gaiia_mapping_live.html.heex create mode 100644 priv/repo/migrations/20260213160011_create_gaiia_tables.exs create mode 100644 test/towerops/gaiia/account_test.exs create mode 100644 test/towerops/gaiia/billing_subscription_test.exs create mode 100644 test/towerops/gaiia/client_test.exs create mode 100644 test/towerops/gaiia/inventory_item_test.exs create mode 100644 test/towerops/gaiia/match_suggestions_test.exs create mode 100644 test/towerops/gaiia/network_site_test.exs create mode 100644 test/towerops/gaiia/sync_test.exs create mode 100644 test/towerops/gaiia_test.exs create mode 100644 test/towerops/workers/gaiia_sync_worker_test.exs diff --git a/config/dev.exs b/config/dev.exs index a6f07b85..f07c363c 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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}, diff --git a/config/runtime.exs b/config/runtime.exs index 1c76fdfb..9962a63b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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}, diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex new file mode 100644 index 00000000..ddbe307f --- /dev/null +++ b/lib/towerops/gaiia.ex @@ -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 diff --git a/lib/towerops/gaiia/account.ex b/lib/towerops/gaiia/account.ex new file mode 100644 index 00000000..6a37ce32 --- /dev/null +++ b/lib/towerops/gaiia/account.ex @@ -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 diff --git a/lib/towerops/gaiia/billing_subscription.ex b/lib/towerops/gaiia/billing_subscription.ex new file mode 100644 index 00000000..70e8e45f --- /dev/null +++ b/lib/towerops/gaiia/billing_subscription.ex @@ -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 diff --git a/lib/towerops/gaiia/client.ex b/lib/towerops/gaiia/client.ex new file mode 100644 index 00000000..40ae2eaa --- /dev/null +++ b/lib/towerops/gaiia/client.ex @@ -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 diff --git a/lib/towerops/gaiia/inventory_item.ex b/lib/towerops/gaiia/inventory_item.ex new file mode 100644 index 00000000..b47b508d --- /dev/null +++ b/lib/towerops/gaiia/inventory_item.ex @@ -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 diff --git a/lib/towerops/gaiia/network_site.ex b/lib/towerops/gaiia/network_site.ex new file mode 100644 index 00000000..1a564e9f --- /dev/null +++ b/lib/towerops/gaiia/network_site.ex @@ -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 diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex new file mode 100644 index 00000000..8a99b36c --- /dev/null +++ b/lib/towerops/gaiia/sync.ex @@ -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 diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index 3fc4feca..cd95a041 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -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 diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex index 96f3a123..15916c30 100644 --- a/lib/towerops/sites.ex +++ b/lib/towerops/sites.ex @@ -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. """ diff --git a/lib/towerops/workers/gaiia_sync_worker.ex b/lib/towerops/workers/gaiia_sync_worker.ex new file mode 100644 index 00000000..d7a41af7 --- /dev/null +++ b/lib/towerops/workers/gaiia_sync_worker.ex @@ -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 diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.ex b/lib/towerops_web/live/org/gaiia_mapping_live.ex new file mode 100644 index 00000000..4d8afc77 --- /dev/null +++ b/lib/towerops_web/live/org/gaiia_mapping_live.ex @@ -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 diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex new file mode 100644 index 00000000..66561879 --- /dev/null +++ b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex @@ -0,0 +1,357 @@ + +
+
+ <.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 + +
+

+ Gaiia Entity Mapping +

+

+ Link Gaiia entities to your Towerops sites and devices. Match suggestions are shown in amber. +

+
+ + <%!-- Tab navigation --%> +
+ +
+ + <%!-- Filter tabs --%> +
+ <.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)} + +
+ + <%!-- Content --%> +
+ <%= if @items == [] do %> +
+ <.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" + /> +

+ <%= if @tab == "sites" do %> + No network sites found + <% else %> + No inventory items found + <% end %> +

+

+ <%= 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 %> +

+
+ <% else %> +
+ + + + + + <%= if @tab == "devices" do %> + + <% end %> + + + + + + <%= for item <- @items do %> + + + + <%= if @tab == "devices" do %> + + <% end %> + + + + <%!-- Inline linking row --%> + <%= if item.gaiia_id == @linking_id do %> + + + + <% end %> + <% end %> + +
+ Name + + Gaiia ID + + {if @tab == "sites", do: "Linked Site", else: "Linked Device"} + + Actions +
+
+ {item.name || "Unnamed"} + <%= if Map.has_key?(@suggestions, item.gaiia_id) do %> + + <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Suggestion + + <% end %> +
+
+ {item.gaiia_id} + + <%= if @tab == "sites" and item.site do %> + + {item.site.name} + + <% 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} + + <% end %> + <%= if (@tab == "sites" and is_nil(item.site_id)) or (@tab == "devices" and is_nil(item.device_id)) do %> + - + <% end %> + + <%= if @tab == "sites" do %> + <%= if item.site_id do %> + + <% else %> + + <% end %> + <% else %> + <%= if item.device_id do %> + + <% else %> + + <% end %> + <% end %> +
+
+
+

+ <%= if @tab == "sites" do %> + Search for a site to link to "{item.name}" + <% else %> + Search for a device to link to "{item.name}" + <% end %> +

+ <%!-- Show suggestions if available --%> + <%= if suggestions = @suggestions[item.gaiia_id] do %> +
+

+ Suggested matches: +

+
+ <%= for suggestion <- suggestions do %> + + <% end %> +
+
+ <% end %> +
+ +
+ + <%= if @search_results != [] do %> +
+
    +
  • + +
  • +
+
+ <% end %> + + <%= if @search_query != "" and String.length(@search_query) >= 2 and @search_results == [] do %> +

+ No results found matching "{@search_query}" +

+ <% end %> +
+ +
+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 2dffd9f5..ed2d1b6c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/priv/repo/migrations/20260213160011_create_gaiia_tables.exs b/priv/repo/migrations/20260213160011_create_gaiia_tables.exs new file mode 100644 index 00000000..eca7d755 --- /dev/null +++ b/priv/repo/migrations/20260213160011_create_gaiia_tables.exs @@ -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 diff --git a/test/towerops/gaiia/account_test.exs b/test/towerops/gaiia/account_test.exs new file mode 100644 index 00000000..dadefb26 --- /dev/null +++ b/test/towerops/gaiia/account_test.exs @@ -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 diff --git a/test/towerops/gaiia/billing_subscription_test.exs b/test/towerops/gaiia/billing_subscription_test.exs new file mode 100644 index 00000000..a414e574 --- /dev/null +++ b/test/towerops/gaiia/billing_subscription_test.exs @@ -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 diff --git a/test/towerops/gaiia/client_test.exs b/test/towerops/gaiia/client_test.exs new file mode 100644 index 00000000..3e6d57b2 --- /dev/null +++ b/test/towerops/gaiia/client_test.exs @@ -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 diff --git a/test/towerops/gaiia/inventory_item_test.exs b/test/towerops/gaiia/inventory_item_test.exs new file mode 100644 index 00000000..3d5ee86b --- /dev/null +++ b/test/towerops/gaiia/inventory_item_test.exs @@ -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 diff --git a/test/towerops/gaiia/match_suggestions_test.exs b/test/towerops/gaiia/match_suggestions_test.exs new file mode 100644 index 00000000..51eec6a9 --- /dev/null +++ b/test/towerops/gaiia/match_suggestions_test.exs @@ -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 diff --git a/test/towerops/gaiia/network_site_test.exs b/test/towerops/gaiia/network_site_test.exs new file mode 100644 index 00000000..af185e7c --- /dev/null +++ b/test/towerops/gaiia/network_site_test.exs @@ -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 diff --git a/test/towerops/gaiia/sync_test.exs b/test/towerops/gaiia/sync_test.exs new file mode 100644 index 00000000..b8654d84 --- /dev/null +++ b/test/towerops/gaiia/sync_test.exs @@ -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 diff --git a/test/towerops/gaiia_test.exs b/test/towerops/gaiia_test.exs new file mode 100644 index 00000000..91773164 --- /dev/null +++ b/test/towerops/gaiia_test.exs @@ -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 diff --git a/test/towerops/workers/gaiia_sync_worker_test.exs b/test/towerops/workers/gaiia_sync_worker_test.exs new file mode 100644 index 00000000..5974af50 --- /dev/null +++ b/test/towerops/workers/gaiia_sync_worker_test.exs @@ -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