From 213a6f2ef10948527ded9f4fafb23384f62b9fed Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 26 Jul 2026 09:57:09 -0500 Subject: [PATCH] test: increase coverage for polling and Gaiia flows --- .../towerops/gaiia/inventory_creator_test.exs | 242 ++++++++++++++ test/towerops/gaiia_test.exs | 98 ++++++ .../status_pages/status_page_config_test.exs | 78 +++++ .../live/graph_live/polling_test.exs | 310 ++++++++++++++++++ .../org/gaiia_reconciliation_live_test.exs | 83 +++++ 5 files changed, 811 insertions(+) create mode 100644 test/towerops/gaiia/inventory_creator_test.exs create mode 100644 test/towerops/status_pages/status_page_config_test.exs create mode 100644 test/towerops_web/live/graph_live/polling_test.exs 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