test: increase coverage for polling and Gaiia flows
This commit is contained in:
parent
a1ea1ffb65
commit
213a6f2ef1
5 changed files with 811 additions and 0 deletions
242
test/towerops/gaiia/inventory_creator_test.exs
Normal file
242
test/towerops/gaiia/inventory_creator_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
78
test/towerops/status_pages/status_page_config_test.exs
Normal file
78
test/towerops/status_pages/status_page_config_test.exs
Normal file
|
|
@ -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); }",
|
||||
"</style><div>unsafe</div>",
|
||||
"</script>",
|
||||
"<SCRIPT>alert('unsafe')</SCRIPT>"
|
||||
]
|
||||
|
||||
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
|
||||
310
test/towerops_web/live/graph_live/polling_test.exs
Normal file
310
test/towerops_web/live/graph_live/polling_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue