diff --git a/test/towerops/gaiia/inventory_creator_test.exs b/test/towerops/gaiia/inventory_creator_test.exs
new file mode 100644
index 00000000..aad29428
--- /dev/null
+++ b/test/towerops/gaiia/inventory_creator_test.exs
@@ -0,0 +1,242 @@
+defmodule Towerops.Gaiia.InventoryCreatorTest do
+ use Towerops.DataCase, async: false
+
+ import Towerops.DevicesFixtures
+ import Towerops.IntegrationsFixtures
+ import Towerops.SnmpFixtures
+
+ alias Towerops.Gaiia.InventoryCreator
+ alias Towerops.Snmp.Interface
+
+ setup do
+ previous_options = Application.get_env(:gaiia, :req_options)
+ Application.put_env(:gaiia, :req_options, plug: {Req.Test, __MODULE__}, retry: false)
+
+ on_exit(fn ->
+ if previous_options do
+ Application.put_env(:gaiia, :req_options, previous_options)
+ else
+ Application.delete_env(:gaiia, :req_options)
+ end
+ end)
+
+ device = device_fixture()
+
+ %{device: device, organization_id: device.organization_id}
+ end
+
+ describe "build_client/1" do
+ test "reports a missing integration", %{organization_id: organization_id} do
+ assert {:error, :no_gaiia_integration} = InventoryCreator.build_client(organization_id)
+ end
+
+ test "reports missing credentials", %{organization_id: organization_id} do
+ integration_fixture(organization_id, %{provider: "gaiia", credentials: %{}})
+
+ assert {:error, :no_api_key} = InventoryCreator.build_client(organization_id)
+ end
+
+ test "ignores disabled integrations", %{organization_id: organization_id} do
+ integration_fixture(organization_id, %{provider: "gaiia", enabled: false})
+
+ assert {:error, :no_gaiia_integration} = InventoryCreator.build_client(organization_id)
+ end
+
+ test "builds a client with the configured API key", %{organization_id: organization_id} do
+ integration_fixture(organization_id, %{
+ provider: "gaiia",
+ credentials: %{"api_key" => "secret-key"}
+ })
+
+ assert {:ok, client} = InventoryCreator.build_client(organization_id)
+ assert {"x-gaiia-api-key", "secret-key"} in client.headers
+ end
+ end
+
+ describe "list_manufacturers/1" do
+ test "deduplicates, removes missing manufacturers, and sorts by name", context do
+ create_gaiia_integration(context.organization_id)
+
+ stub_success(%{
+ "inventoryModels" => %{
+ "edges" => [
+ %{"node" => %{"manufacturer" => %{"id" => "m2", "name" => "Zeta"}}},
+ %{"node" => %{"manufacturer" => nil}},
+ %{"node" => %{"manufacturer" => %{"id" => "m1", "name" => "Alpha"}}},
+ %{"node" => %{"manufacturer" => %{"id" => "m1", "name" => "Alpha"}}}
+ ]
+ }
+ })
+
+ assert {:ok, manufacturers} = InventoryCreator.list_manufacturers(context.organization_id)
+ assert Enum.map(manufacturers, & &1["id"]) == ["m1", "m2"]
+ end
+
+ test "maps API failures to a stable error", %{organization_id: organization_id} do
+ create_gaiia_integration(organization_id)
+ stub_http_error()
+
+ assert {:error, :gaiia_api_error} = InventoryCreator.list_manufacturers(organization_id)
+ end
+ end
+
+ describe "list_models/2" do
+ test "filters by manufacturer and sorts by model name", %{organization_id: organization_id} do
+ create_gaiia_integration(organization_id)
+
+ stub_success(%{
+ "inventoryModels" => %{
+ "edges" => [
+ model_edge("2", "Zulu", "wanted"),
+ model_edge("3", "Ignored", "other"),
+ model_edge("1", "Alpha", "wanted")
+ ]
+ }
+ })
+
+ assert {:ok, models} = InventoryCreator.list_models(organization_id, "wanted")
+ assert Enum.map(models, & &1["id"]) == ["1", "2"]
+ end
+
+ test "maps API failures to a stable error", %{organization_id: organization_id} do
+ create_gaiia_integration(organization_id)
+ stub_http_error()
+
+ assert {:error, :gaiia_api_error} = InventoryCreator.list_models(organization_id, "manufacturer")
+ end
+ end
+
+ describe "create_inventory_item/4" do
+ test "creates an assigned item using the device MAC address", context do
+ create_gaiia_integration(context.organization_id)
+ add_interface(context.device, "AA:BB:CC:DD:EE:FF")
+
+ Req.Test.stub(__MODULE__, fn conn ->
+ body = read_json_body(conn)
+
+ if String.contains?(body["query"], "inventoryModels") do
+ graphql(conn, %{
+ "inventoryModels" => %{
+ "edges" => [
+ %{
+ "node" => %{
+ "fields" => %{
+ "edges" => [
+ %{"node" => %{"id" => "serial", "isMainIdentifier" => false}},
+ %{"node" => %{"id" => "mac-field", "isMainIdentifier" => true}}
+ ]
+ }
+ }
+ }
+ ]
+ }
+ })
+ else
+ input = body["variables"]["input"]
+ assert input["modelId"] == "model-1"
+ assert input["items"] == [%{"fields" => [%{"modelFieldId" => "mac-field", "data" => "AA:BB:CC:DD:EE:FF"}]}]
+ assert input["assignation"] == %{"assigneeId" => "site-1", "assigneeType" => "INVENTORY_LOCATION"}
+ graphql(conn, %{"startAddInventoryItemsJob" => "job-1"})
+ end
+ end)
+
+ assert {:ok, "job-1"} =
+ InventoryCreator.create_inventory_item(
+ context.organization_id,
+ context.device,
+ "model-1",
+ "site-1"
+ )
+ end
+
+ test "creates an unassigned item when no model fields are returned", context do
+ create_gaiia_integration(context.organization_id)
+
+ Req.Test.stub(__MODULE__, fn conn ->
+ body = read_json_body(conn)
+
+ if String.contains?(body["query"], "inventoryModels") do
+ graphql(conn, %{"inventoryModels" => %{"edges" => []}})
+ else
+ input = body["variables"]["input"]
+ assert input["items"] == [%{"fields" => []}]
+ refute Map.has_key?(input, "assignation")
+ graphql(conn, %{"startAddInventoryItemsJob" => %{"id" => "job-result"}})
+ end
+ end)
+
+ assert {:ok, %{"id" => "job-result"}} =
+ InventoryCreator.create_inventory_item(context.organization_id, context.device, "model-1")
+ end
+
+ test "returns an API error when model fields cannot be fetched", context do
+ create_gaiia_integration(context.organization_id)
+ stub_http_error()
+
+ assert {:error, :gaiia_api_error} =
+ InventoryCreator.create_inventory_item(context.organization_id, context.device, "model-1")
+ end
+
+ test "returns the mutation error", context do
+ create_gaiia_integration(context.organization_id)
+
+ Req.Test.stub(__MODULE__, fn conn ->
+ body = read_json_body(conn)
+
+ if String.contains?(body["query"], "inventoryModels") do
+ graphql(conn, %{
+ "inventoryModels" => %{
+ "edges" => [%{"node" => %{"fields" => %{"edges" => []}}}]
+ }
+ })
+ else
+ conn
+ |> Plug.Conn.put_status(500)
+ |> Req.Test.json(%{"error" => "failed"})
+ end
+ end)
+
+ assert {:error, %Gaiia.Error{kind: :http}} =
+ InventoryCreator.create_inventory_item(context.organization_id, context.device, "model-1")
+ end
+ end
+
+ defp create_gaiia_integration(organization_id) do
+ integration_fixture(organization_id, %{provider: "gaiia"})
+ end
+
+ defp model_edge(id, name, manufacturer_id) do
+ %{"node" => %{"id" => id, "name" => name, "manufacturer" => %{"id" => manufacturer_id}}}
+ end
+
+ defp add_interface(device, mac) do
+ snmp_device = snmp_device_fixture(%{device: device})
+
+ %Interface{}
+ |> Interface.changeset(%{
+ snmp_device_id: snmp_device.id,
+ if_index: 1,
+ if_phys_address: mac
+ })
+ |> Repo.insert!()
+ end
+
+ defp stub_success(data) do
+ Req.Test.stub(__MODULE__, &graphql(&1, data))
+ end
+
+ defp stub_http_error do
+ Req.Test.stub(__MODULE__, fn conn ->
+ conn
+ |> Plug.Conn.put_status(500)
+ |> Req.Test.json(%{"error" => "failed"})
+ end)
+ end
+
+ defp graphql(conn, data), do: Req.Test.json(conn, %{"data" => data})
+
+ defp read_json_body(conn) do
+ {:ok, body, _conn} = Plug.Conn.read_body(conn)
+ Jason.decode!(body)
+ end
+end
diff --git a/test/towerops/gaiia_test.exs b/test/towerops/gaiia_test.exs
index d187d6f9..a05cc565 100644
--- a/test/towerops/gaiia_test.exs
+++ b/test/towerops/gaiia_test.exs
@@ -43,6 +43,24 @@ defmodule Towerops.GaiiaTest do
assert %{name: "Alice"} = Gaiia.get_account(org.id, "acct-1")
assert is_nil(Gaiia.get_account(org.id, "nonexistent"))
end
+
+ test "count_active_subscribers/1 counts accounts with subscriptions", %{org: org} do
+ {:ok, _} =
+ Gaiia.upsert_account(org.id, %{
+ gaiia_id: "active",
+ name: "Active",
+ subscription_count: 2
+ })
+
+ {:ok, _} =
+ Gaiia.upsert_account(org.id, %{
+ gaiia_id: "inactive",
+ name: "Inactive",
+ subscription_count: 0
+ })
+
+ assert Gaiia.count_active_subscribers(org.id) == 1
+ end
end
describe "network_sites" do
@@ -71,6 +89,20 @@ defmodule Towerops.GaiiaTest 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
+
+ test "gets a network site by Gaiia ID and by mapped Towerops site", %{org: org} do
+ towerops_site = create_site(org.id)
+
+ {:ok, network_site} =
+ Gaiia.upsert_network_site(org.id, %{
+ gaiia_id: "site-lookup",
+ name: "Lookup",
+ site_id: towerops_site.id
+ })
+
+ assert Gaiia.get_network_site(org.id, "site-lookup").id == network_site.id
+ assert Gaiia.get_network_site_for_site(towerops_site.id).id == network_site.id
+ end
end
describe "inventory_items" do
@@ -96,6 +128,20 @@ defmodule Towerops.GaiiaTest do
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD"})
assert length(Gaiia.list_inventory_items(org.id)) == 1
end
+
+ test "gets an inventory item by Gaiia ID and mapped device", %{org: org} do
+ device = device_fixture(%{organization_id: org.id})
+
+ {:ok, item} =
+ Gaiia.upsert_inventory_item(org.id, %{
+ gaiia_id: "item-lookup",
+ name: "Lookup",
+ device_id: device.id
+ })
+
+ assert Gaiia.get_inventory_item(org.id, "item-lookup").id == item.id
+ assert Gaiia.get_inventory_item_for_device(device.id).id == item.id
+ end
end
describe "billing_subscriptions" do
@@ -117,6 +163,29 @@ defmodule Towerops.GaiiaTest do
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-2"})
assert length(Gaiia.list_billing_subscriptions(org.id)) == 2
end
+
+ test "gets subscriptions and sums active MRR", %{org: org} do
+ {:ok, active} =
+ Gaiia.upsert_billing_subscription(org.id, %{
+ gaiia_id: "active-sub",
+ status: "ACTIVE",
+ mrr_amount: Decimal.new("42.50")
+ })
+
+ {:ok, _inactive} =
+ Gaiia.upsert_billing_subscription(org.id, %{
+ gaiia_id: "inactive-sub",
+ status: "CANCELLED",
+ mrr_amount: Decimal.new("100.00")
+ })
+
+ assert Gaiia.get_billing_subscription(org.id, "active-sub").id == active.id
+ assert Decimal.equal?(Gaiia.sum_active_mrr(org.id), Decimal.new("42.50"))
+ end
+
+ test "sum_active_mrr/1 returns zero when there are no subscriptions", %{org: org} do
+ assert Decimal.equal?(Gaiia.sum_active_mrr(org.id), Decimal.new(0))
+ end
end
defp create_site(organization_id) do
@@ -172,6 +241,35 @@ defmodule Towerops.GaiiaTest do
end
end
+ describe "batch summaries" do
+ test "empty device and site lists return empty maps", %{org: org} do
+ assert Gaiia.get_device_subscriber_counts([]) == %{}
+ assert Gaiia.get_site_subscriber_summaries([]) == %{}
+ assert Gaiia.get_site_subscriber_summaries(:invalid) == %{}
+ assert Gaiia.get_device_impact(Ecto.UUID.generate()) == %{accounts: [], mrr: Decimal.new(0), subscriber_count: 0}
+
+ assert Gaiia.list_missing_subscribers(org.id) == []
+ end
+
+ test "returns mapped site summaries in one query", %{org: org} do
+ site = create_site(org.id)
+
+ {:ok, _network_site} =
+ Gaiia.upsert_network_site(org.id, %{
+ gaiia_id: "batch-site",
+ name: "Batch",
+ site_id: site.id,
+ account_count: 7,
+ total_mrr: Decimal.new("80.00")
+ })
+
+ site_id = site.id
+ assert %{^site_id => summary} = Gaiia.get_site_subscriber_summaries([site.id])
+ assert summary.account_count == 7
+ assert Decimal.equal?(summary.total_mrr, Decimal.new("80.00"))
+ end
+ end
+
describe "get_devices_impact/1" do
test "deduplicates accounts across devices, preferring higher-confidence matches", %{org: org} do
site = create_site(org.id)
diff --git a/test/towerops/status_pages/status_page_config_test.exs b/test/towerops/status_pages/status_page_config_test.exs
new file mode 100644
index 00000000..1760d981
--- /dev/null
+++ b/test/towerops/status_pages/status_page_config_test.exs
@@ -0,0 +1,78 @@
+defmodule Towerops.StatusPages.StatusPageConfigTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.StatusPages.StatusPageConfig
+
+ describe "changeset/2" do
+ test "accepts valid configuration" do
+ changeset =
+ StatusPageConfig.changeset(%StatusPageConfig{}, %{
+ slug: "network-status",
+ organization_id: Ecto.UUID.generate(),
+ custom_css: ".status { color: green; }"
+ })
+
+ assert changeset.valid?
+ end
+
+ test "requires a slug and organization" do
+ errors =
+ %StatusPageConfig{}
+ |> StatusPageConfig.changeset(%{})
+ |> errors_on()
+
+ assert "can't be blank" in errors.slug
+ assert "can't be blank" in errors.organization_id
+ end
+
+ test "validates slug format and length" do
+ organization_id = Ecto.UUID.generate()
+
+ for slug <- ["UPPERCASE", "-leading", "trailing-", "ab"] do
+ changeset =
+ StatusPageConfig.changeset(%StatusPageConfig{}, %{
+ slug: slug,
+ organization_id: organization_id
+ })
+
+ refute changeset.valid?
+ assert errors_on(changeset).slug != []
+ end
+ end
+
+ test "rejects external resources and HTML in custom CSS" do
+ organization_id = Ecto.UUID.generate()
+
+ unsafe_css = [
+ "body { background: URL(https://example.com/image.png); }",
+ "
unsafe
",
+ "",
+ ""
+ ]
+
+ for custom_css <- unsafe_css do
+ changeset =
+ StatusPageConfig.changeset(%StatusPageConfig{}, %{
+ slug: "network-status",
+ organization_id: organization_id,
+ custom_css: custom_css
+ })
+
+ refute changeset.valid?
+ assert errors_on(changeset).custom_css != []
+ end
+ end
+
+ test "does not revalidate unchanged custom CSS" do
+ config = %StatusPageConfig{custom_css: ".status { color: green; }"}
+
+ changeset =
+ StatusPageConfig.changeset(config, %{
+ slug: "network-status",
+ organization_id: Ecto.UUID.generate()
+ })
+
+ assert changeset.valid?
+ end
+ end
+end
diff --git a/test/towerops_web/live/graph_live/polling_test.exs b/test/towerops_web/live/graph_live/polling_test.exs
new file mode 100644
index 00000000..85b82234
--- /dev/null
+++ b/test/towerops_web/live/graph_live/polling_test.exs
@@ -0,0 +1,310 @@
+defmodule ToweropsWeb.GraphLive.PollingTest do
+ use Towerops.DataCase, async: false
+
+ import Mox
+ import Towerops.DevicesFixtures
+ import Towerops.SnmpFixtures
+
+ alias Towerops.Snmp.Interface
+ alias Towerops.Snmp.Sensor
+ alias Towerops.Snmp.SnmpMock
+ alias ToweropsWeb.GraphLive.Polling
+
+ setup :set_mox_global
+ setup :verify_on_exit!
+
+ describe "build_snmp_client_opts/1" do
+ test "builds client options from the device configuration" do
+ device =
+ device_fixture(%{
+ ip_address: "192.0.2.10",
+ snmp_community: "monitoring",
+ snmp_version: "2c",
+ snmp_port: 1161
+ })
+
+ assert [
+ ip: "192.0.2.10",
+ community: "monitoring",
+ version: "2c",
+ port: 1161,
+ timeout: 3_000
+ ] = Polling.build_snmp_client_opts(device)
+ end
+ end
+
+ describe "poll_regular_sensors/3" do
+ test "returns rounded points and drops failed or non-numeric readings" do
+ snmp_device = snmp_device_fixture()
+ successful = insert_sensor(snmp_device, %{sensor_index: "1", sensor_divisor: 10})
+ _failed = insert_sensor(snmp_device, %{sensor_index: "2", sensor_oid: "1.3.6.1.4.1.999.2"})
+ _non_numeric = insert_sensor(snmp_device, %{sensor_index: "3", sensor_oid: "1.3.6.1.4.1.999.3"})
+
+ expect(SnmpMock, :get, 3, fn _target, oid, _opts ->
+ case oid do
+ "1.3.6.1.4.1.999.1" -> {:ok, {:integer, 215}}
+ "1.3.6.1.4.1.999.2" -> {:error, :timeout}
+ "1.3.6.1.4.1.999.3" -> {:ok, {:octet_string, "unknown"}}
+ end
+ end)
+
+ assigns = %{sensor_id: nil, sensor_type: "temperature", device_id: snmp_device.device_id}
+ assert [point] = Polling.poll_regular_sensors(assigns, client_opts(), 123_456)
+ assert point == %{sensor_id: successful.id, label: "Temperature", value: 21.5, timestamp: 123_456}
+ end
+
+ test "poll_sensors dispatches regular sensor types" do
+ snmp_device = snmp_device_fixture()
+ sensor = insert_sensor(snmp_device, %{sensor_divisor: 2})
+
+ expect(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, {:integer, 15}} end)
+
+ assigns = %{sensor_id: sensor.id, sensor_type: "temperature", device_id: snmp_device.device_id}
+ assert [%{value: 7.5}] = Polling.poll_sensors(assigns, client_opts(), 100, nil)
+ end
+ end
+
+ describe "poll_traffic_with_state/4" do
+ test "returns empty state for missing devices and devices without interfaces" do
+ assert {[], nil} =
+ Polling.poll_traffic_with_state(
+ %{device_id: Ecto.UUID.generate()},
+ client_opts(),
+ 100,
+ nil
+ )
+
+ snmp_device = snmp_device_fixture()
+
+ assert {[], nil} =
+ Polling.poll_traffic_with_state(
+ %{device_id: snmp_device.device_id},
+ client_opts(),
+ 100,
+ nil
+ )
+ end
+
+ test "captures initial counters and calculates aggregate traffic on the next poll" do
+ snmp_device = snmp_device_fixture()
+ interface = insert_interface(snmp_device)
+
+ expect_octet_counters(1_000, 2_000)
+
+ assigns = %{device_id: snmp_device.device_id}
+ assert {[], [current]} = Polling.poll_traffic_with_state(assigns, client_opts(), 100, nil)
+ assert current.interface_id == interface.id
+ assert current.interface_name == "ether1"
+
+ previous = [
+ %{
+ current
+ | if_in_octets: 500,
+ if_out_octets: 1_000,
+ polled_at: DateTime.add(current.polled_at, -10, :second)
+ }
+ ]
+
+ expect_octet_counters(1_500, 3_000)
+
+ assert {[inbound, outbound], [_updated]} =
+ Polling.poll_traffic_with_state(assigns, client_opts(), 200, previous)
+
+ assert inbound.sensor_id == "traffic_in"
+ assert inbound.value > 0
+ assert outbound.sensor_id == "traffic_out"
+ assert outbound.value > inbound.value
+ end
+
+ test "falls back to 32-bit counters when high-capacity counters fail" do
+ snmp_device = snmp_device_fixture()
+ _interface = insert_interface(snmp_device)
+
+ expect(SnmpMock, :get, 3, fn _target, oid, _opts ->
+ cond do
+ String.contains?(oid, ".31.1.1.1.") -> {:error, :no_such_object}
+ String.contains?(oid, ".2.2.1.10.") -> {:ok, {:counter32, 100}}
+ String.contains?(oid, ".2.2.1.16.") -> {:ok, {:counter32, 200}}
+ end
+ end)
+
+ assert {[], [%{if_in_octets: 100, if_out_octets: 200}]} =
+ Polling.poll_traffic_with_state(
+ %{device_id: snmp_device.device_id},
+ client_opts(version: "1"),
+ 100,
+ nil
+ )
+ end
+ end
+
+ describe "request_agent_live_poll/3" do
+ test "publishes requested OIDs and converts valid replies into points" do
+ snmp_device = snmp_device_fixture()
+ sensor = insert_sensor(snmp_device, %{sensor_divisor: 10})
+ agent_token_id = Ecto.UUID.generate()
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token_id}:live_poll")
+
+ task =
+ Task.async(fn ->
+ Polling.request_agent_live_poll(
+ snmp_device.device_id,
+ agent_token_id,
+ %{sensor_id: sensor.id}
+ )
+ end)
+
+ assert_receive {:live_poll_requested, device_id, [oid], reply_topic}
+ assert device_id == snmp_device.device_id
+ assert oid == sensor.sensor_oid
+
+ Phoenix.PubSub.broadcast(
+ Towerops.PubSub,
+ reply_topic,
+ {:live_poll_result, %{sensor.sensor_oid => "215.5"}}
+ )
+
+ assert [%{sensor_id: sensor_id, value: 21.6, timestamp: timestamp}] = Task.await(task)
+ assert sensor_id == sensor.id
+ assert is_integer(timestamp)
+ end
+
+ test "drops missing and unparsable values from agent replies" do
+ snmp_device = snmp_device_fixture()
+ sensor = insert_sensor(snmp_device)
+ agent_token_id = Ecto.UUID.generate()
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token_id}:live_poll")
+
+ task =
+ Task.async(fn ->
+ Polling.request_agent_live_poll(
+ snmp_device.device_id,
+ agent_token_id,
+ %{sensor_id: sensor.id}
+ )
+ end)
+
+ assert_receive {:live_poll_requested, _, _, reply_topic}
+ Phoenix.PubSub.broadcast(Towerops.PubSub, reply_topic, {:live_poll_result, %{sensor.sensor_oid => "bad"}})
+ assert [] == Task.await(task)
+ end
+ end
+
+ describe "get_sensors_for_live_mode/1" do
+ test "returns a specifically selected sensor" do
+ snmp_device = snmp_device_fixture()
+ sensor = insert_sensor(snmp_device, %{sensor_type: "temperature", sensor_index: "2"})
+
+ assert [selected] = Polling.get_sensors_for_live_mode(%{sensor_id: sensor.id})
+ assert selected.id == sensor.id
+ end
+
+ test "returns an empty list for a missing selected sensor" do
+ assert [] == Polling.get_sensors_for_live_mode(%{sensor_id: Ecto.UUID.generate()})
+ end
+
+ test "maps graph types, filters sensors, and sorts by index" do
+ snmp_device = snmp_device_fixture()
+ later = insert_sensor(snmp_device, %{sensor_type: "cpu_temperature", sensor_index: "20"})
+ earlier = insert_sensor(snmp_device, %{sensor_type: "temperature", sensor_index: "10"})
+ _other = insert_sensor(snmp_device, %{sensor_type: "voltage", sensor_index: "1"})
+
+ sensors =
+ Polling.get_sensors_for_live_mode(%{
+ sensor_id: nil,
+ sensor_type: "temperature",
+ device_id: snmp_device.device_id
+ })
+
+ assert Enum.map(sensors, & &1.id) == [earlier.id, later.id]
+ end
+
+ test "returns no sensors for traffic, unknown devices, or unknown sensors" do
+ snmp_device = snmp_device_fixture()
+
+ assert [] ==
+ Polling.get_sensors_for_live_mode(%{
+ sensor_id: nil,
+ sensor_type: "traffic",
+ device_id: snmp_device.device_id
+ })
+
+ assert [] ==
+ Polling.get_sensors_for_live_mode(%{
+ sensor_id: nil,
+ sensor_type: "temperature",
+ device_id: Ecto.UUID.generate()
+ })
+ end
+ end
+
+ describe "update_buffer/4" do
+ test "prepends points into independent sensor buffers" do
+ points = [
+ %{sensor_id: "temperature", timestamp: 100, value: 21.5},
+ %{sensor_id: "voltage", timestamp: 100, value: 48.2}
+ ]
+
+ assert {buffer, 1} = Polling.update_buffer(%{}, 0, points, 3)
+ assert buffer["temperature"] == [%{x: 100, y: 21.5}]
+ assert buffer["voltage"] == [%{x: 100, y: 48.2}]
+ end
+
+ test "trims each sensor buffer and caps the count" do
+ buffer = %{
+ sensor: [
+ %{x: 2, y: 2.0},
+ %{x: 1, y: 1.0},
+ %{x: 0, y: 0.0}
+ ]
+ }
+
+ points = [%{sensor_id: :sensor, timestamp: 3, value: 3.0}]
+
+ assert {%{sensor: values}, 3} = Polling.update_buffer(buffer, 3, points, 3)
+ assert values == [%{x: 3, y: 3.0}, %{x: 2, y: 2.0}, %{x: 1, y: 1.0}]
+ end
+
+ test "increments the polling count even when no points arrive" do
+ assert {%{}, 2} = Polling.update_buffer(%{}, 1, [], 3)
+ end
+ end
+
+ defp insert_sensor(snmp_device, attrs \\ %{}) do
+ defaults = %{
+ snmp_device_id: snmp_device.id,
+ sensor_index: "1",
+ sensor_type: "temperature",
+ sensor_descr: "Temperature",
+ sensor_oid: "1.3.6.1.4.1.999.1",
+ sensor_divisor: 1
+ }
+
+ %Sensor{}
+ |> Sensor.changeset(Map.merge(defaults, attrs))
+ |> Repo.insert!()
+ end
+
+ defp insert_interface(snmp_device) do
+ %Interface{}
+ |> Interface.changeset(%{
+ snmp_device_id: snmp_device.id,
+ if_index: 1,
+ if_name: "ether1"
+ })
+ |> Repo.insert!()
+ end
+
+ defp expect_octet_counters(in_octets, out_octets) do
+ expect(SnmpMock, :get, 2, fn _target, oid, _opts ->
+ if String.contains?(oid, ".1.10."), do: {:ok, {:counter64, out_octets}}, else: {:ok, {:counter64, in_octets}}
+ end)
+ end
+
+ defp client_opts(overrides \\ []) do
+ Keyword.merge(
+ [ip: "192.0.2.10", community: "public", version: "2c", port: 161, timeout: 3_000],
+ overrides
+ )
+ end
+end
diff --git a/test/towerops_web/live/org/gaiia_reconciliation_live_test.exs b/test/towerops_web/live/org/gaiia_reconciliation_live_test.exs
index d7839646..2ec8b841 100644
--- a/test/towerops_web/live/org/gaiia_reconciliation_live_test.exs
+++ b/test/towerops_web/live/org/gaiia_reconciliation_live_test.exs
@@ -2,6 +2,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
+ import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
setup :register_and_log_in_user
@@ -33,4 +34,86 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLiveTest do
assert html =~ "Gaiia"
end
+
+ test "refreshes and attempts automatic matching", %{conn: conn, org: org} do
+ {:ok, view, _html} =
+ live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
+
+ render_hook(view, "refresh", %{})
+ render_hook(view, "auto_match", %{})
+ assert has_element?(view, "h1")
+ end
+
+ test "opens and cancels the link search for an untracked device", %{conn: conn, org: org} do
+ device = device_fixture(%{organization_id: org.id})
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=untracked"
+ )
+
+ render_hook(view, "start_link_search", %{"device_id" => device.id})
+ assert has_element?(view, "#search-gaiia-items-form-#{device.id}")
+
+ render_hook(view, "cancel_link_search", %{})
+ refute has_element?(view, "#search-gaiia-items-form-#{device.id}")
+ end
+
+ test "short searches return no matches", %{conn: conn, org: org} do
+ device = device_fixture(%{organization_id: org.id})
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=untracked"
+ )
+
+ render_hook(view, "start_link_search", %{"device_id" => device.id})
+ render_hook(view, "search_gaiia_items", %{"query" => "x"})
+
+ assert has_element?(
+ view,
+ "#search-gaiia-items-form-#{device.id} input[name=query][value=x]"
+ )
+ end
+
+ test "clears manufacturer selection and cancels create state", %{conn: conn, org: org} do
+ {:ok, view, _html} =
+ live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
+
+ render_hook(view, "select_manufacturer", %{"manufacturer_id" => ""})
+ render_hook(view, "cancel_create", %{})
+ assert has_element?(view, "h1")
+ end
+
+ test "rejects creation without a model and handles a missing device", %{conn: conn, org: org} do
+ {:ok, view, _html} =
+ live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
+
+ render_hook(view, "create_gaiia_item", %{
+ "device_id" => Ecto.UUID.generate(),
+ "model_id" => ""
+ })
+
+ render_hook(view, "create_gaiia_item", %{
+ "device_id" => Ecto.UUID.generate(),
+ "model_id" => "model-1"
+ })
+
+ assert has_element?(view, "h1")
+ end
+
+ test "handles attempts to link or unlink missing inventory items", %{conn: conn, org: org} do
+ {:ok, view, _html} =
+ live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
+
+ render_hook(view, "link_to_gaiia", %{
+ "device_id" => Ecto.UUID.generate(),
+ "gaiia_id" => "missing"
+ })
+
+ render_hook(view, "unlink_ghost", %{"gaiia_id" => "missing"})
+ assert has_element?(view, "h1")
+ end
end